@mentra/crust 0.1.0-beta.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.
Files changed (66) hide show
  1. package/README.md +41 -0
  2. package/android/build.gradle +146 -0
  3. package/android/src/internal/AndroidManifest.xml +9 -0
  4. package/android/src/internal/java/com/mentra/crust/receivers/CaptionsTesterIncidentReceiver.kt +46 -0
  5. package/android/src/main/AndroidManifest.xml +19 -0
  6. package/android/src/main/java/com/mentra/crust/CrustModule.kt +1042 -0
  7. package/android/src/main/java/com/mentra/crust/CrustView.kt +30 -0
  8. package/android/src/main/java/com/mentra/crust/heading/HeadingManager.kt +150 -0
  9. package/android/src/main/java/com/mentra/crust/jsc/JSCDispatcher.kt +189 -0
  10. package/android/src/main/java/com/mentra/crust/jsc/JSCPolyfillBridge.kt +287 -0
  11. package/android/src/main/java/com/mentra/crust/jsc/JSCRuntime.kt +593 -0
  12. package/android/src/main/java/com/mentra/crust/navigation/NavigationManager.kt +1445 -0
  13. package/android/src/main/java/com/mentra/crust/services/NotificationListener.kt +319 -0
  14. package/android/src/main/java/com/mentra/crust/utils/ImageProcessor.java +452 -0
  15. package/android/src/main/java/com/mentra/crust/utils/VideoStabilizer.kt +556 -0
  16. package/android/src/main/res/values/strings.xml +3 -0
  17. package/app.plugin.js +3 -0
  18. package/build/Crust.types.d.ts +148 -0
  19. package/build/Crust.types.d.ts.map +1 -0
  20. package/build/Crust.types.js +2 -0
  21. package/build/Crust.types.js.map +1 -0
  22. package/build/CrustModule.d.ts +182 -0
  23. package/build/CrustModule.d.ts.map +1 -0
  24. package/build/CrustModule.js +4 -0
  25. package/build/CrustModule.js.map +1 -0
  26. package/build/CrustModule.web.d.ts +34 -0
  27. package/build/CrustModule.web.d.ts.map +1 -0
  28. package/build/CrustModule.web.js +63 -0
  29. package/build/CrustModule.web.js.map +1 -0
  30. package/build/CrustView.d.ts +4 -0
  31. package/build/CrustView.d.ts.map +1 -0
  32. package/build/CrustView.js +7 -0
  33. package/build/CrustView.js.map +1 -0
  34. package/build/CrustView.web.d.ts +4 -0
  35. package/build/CrustView.web.d.ts.map +1 -0
  36. package/build/CrustView.web.js +7 -0
  37. package/build/CrustView.web.js.map +1 -0
  38. package/build/index.d.ts +4 -0
  39. package/build/index.d.ts.map +1 -0
  40. package/build/index.js +6 -0
  41. package/build/index.js.map +1 -0
  42. package/expo-module.config.json +9 -0
  43. package/ios/Crust.podspec +65 -0
  44. package/ios/CrustModule.swift +781 -0
  45. package/ios/CrustView.swift +38 -0
  46. package/ios/Resources/startup.js +814 -0
  47. package/ios/Source/JSCDispatcher.swift +226 -0
  48. package/ios/Source/JSCPolyfillBridge.swift +378 -0
  49. package/ios/Source/JSCRuntime.swift +673 -0
  50. package/ios/Source/utils/ImageProcessor.swift +392 -0
  51. package/ios/Source/utils/SystemGestures.swift +53 -0
  52. package/ios/Source/utils/VideoStabilizer.swift +374 -0
  53. package/ios/heading/HeadingManager.swift +74 -0
  54. package/ios/navigation/NavPayloads.swift +62 -0
  55. package/ios/navigation/NavigationManager.swift +720 -0
  56. package/package.json +69 -0
  57. package/plugin/build/index.d.ts +19 -0
  58. package/plugin/build/index.js +23 -0
  59. package/plugin/build/withAndroid.d.ts +2 -0
  60. package/plugin/build/withAndroid.js +78 -0
  61. package/src/Crust.types.ts +157 -0
  62. package/src/CrustModule.ts +191 -0
  63. package/src/CrustModule.web.ts +66 -0
  64. package/src/CrustView.tsx +10 -0
  65. package/src/CrustView.web.tsx +11 -0
  66. package/src/index.ts +5 -0
@@ -0,0 +1,556 @@
1
+ package com.mentra.crust.utils
2
+
3
+ import android.graphics.Bitmap
4
+ import android.graphics.Canvas
5
+ import android.graphics.Matrix
6
+ import android.graphics.Paint
7
+ import android.media.Image
8
+ import android.media.MediaCodec
9
+ import android.media.MediaCodecInfo
10
+ import android.media.MediaExtractor
11
+ import android.media.MediaFormat
12
+ import android.media.MediaMuxer
13
+ import android.util.Log
14
+ import org.json.JSONObject
15
+ import java.io.File
16
+ import java.nio.ByteBuffer
17
+ import kotlin.math.abs
18
+ import kotlin.math.max
19
+ import kotlin.math.min
20
+
21
+ /**
22
+ * Gyroscope-based video stabilizer.
23
+ * Uses IMU sidecar data (from ImuRecorder) to apply motion-compensated
24
+ * frame warping, correcting rotation jitter in videos.
25
+ *
26
+ * Algorithm: integrate gyro → cumulative rotation per axis →
27
+ * bidirectional EMA smooth → correction = smooth - actual →
28
+ * per-frame affine transform (rotation + translation).
29
+ */
30
+ object VideoStabilizer {
31
+ private const val TAG = "VideoStabilizer"
32
+
33
+ // EMA smoothing factor: higher = smoother path = more aggressive stabilization.
34
+ // 0.98 aggressively removes head/hand jitter while preserving slow intentional pans.
35
+ private const val SMOOTH_FACTOR = 0.98
36
+
37
+ // Number of bidirectional EMA passes for stronger smoothing.
38
+ private const val SMOOTH_PASSES = 3
39
+
40
+ // Crop margin: fraction of frame to crop on each edge to hide black borders
41
+ // from stabilization shifts. 0.08 = 8% crop per side (16% total zoom).
42
+ private const val CROP_MARGIN = 0.08
43
+
44
+ private const val CODEC_TIMEOUT_US = 10_000L
45
+
46
+ // --- Color pipeline tuning parameters ---
47
+
48
+ // Tone curve anchor Y values (X fixed at 0.0, 0.25, 0.50, 0.75, 1.0)
49
+ // Slight shadow lift (0.02) for low-light camera, full whites, punchy midtone contrast.
50
+ private val TONE_CURVE_Y = doubleArrayOf(0.02, 0.20, 0.52, 0.82, 1.0)
51
+
52
+ // Vibrance: selective saturation boost for desaturated colors (0.0 = off, 1.0 = max)
53
+ private const val VIBRANCE_AMOUNT = 0.45f
54
+
55
+ // Color correction matrix (3x4: RGB coefficients + bias per channel)
56
+ private const val CM_RR = 1.06f; private const val CM_RG = 0.02f; private const val CM_RB = -0.01f; private const val CM_R_BIAS = 5f / 255f
57
+ private const val CM_GR = 0.01f; private const val CM_GG = 1.04f; private const val CM_GB = -0.01f; private const val CM_G_BIAS = 3f / 255f
58
+ private const val CM_BR = -0.02f; private const val CM_BG = 0.01f; private const val CM_BB = 1.02f; private const val CM_B_BIAS = 0f
59
+
60
+ // --- Precomputed LUTs (depend on tuning parameters above) ---
61
+
62
+ // sRGB linearize LUT: sRGB byte (0-255) -> linear float (0.0-1.0)
63
+ private val LINEARIZE_LUT = FloatArray(256) { i ->
64
+ val v = i / 255.0
65
+ if (v <= 0.04045) (v / 12.92).toFloat()
66
+ else Math.pow((v + 0.055) / 1.055, 2.4).toFloat()
67
+ }
68
+
69
+ // sRGB delinearize LUT: linear Q12 (0-4095) -> sRGB byte (0-255)
70
+ private const val DELIN_LUT_SIZE = 4096
71
+ private val DELINEARIZE_LUT = IntArray(DELIN_LUT_SIZE) { i ->
72
+ val v = i.toDouble() / (DELIN_LUT_SIZE - 1)
73
+ val s = if (v <= 0.0031308) v * 12.92
74
+ else 1.055 * Math.pow(v, 1.0 / 2.4) - 0.055
75
+ (s * 255 + 0.5).toInt().coerceIn(0, 255)
76
+ }
77
+
78
+ // Tone curve LUT in linear space: linear Q12 (0-4095) -> linear float (0.0-1.0)
79
+ private val TONE_LUT_LINEAR = FloatArray(DELIN_LUT_SIZE) { i ->
80
+ val x = i.toDouble() / (DELIN_LUT_SIZE - 1)
81
+ val ax = doubleArrayOf(0.0, 0.25, 0.50, 0.75, 1.0)
82
+ val ay = TONE_CURVE_Y
83
+ val y = when {
84
+ x <= ax[1] -> ay[0] + (ay[1] - ay[0]) * (x - ax[0]) / (ax[1] - ax[0])
85
+ x <= ax[2] -> ay[1] + (ay[2] - ay[1]) * (x - ax[1]) / (ax[2] - ax[1])
86
+ x <= ax[3] -> ay[2] + (ay[3] - ay[2]) * (x - ax[2]) / (ax[3] - ax[2])
87
+ else -> ay[3] + (ay[4] - ay[3]) * (x - ax[3]) / (ax[4] - ax[3])
88
+ }
89
+ y.coerceIn(0.0, 1.0).toFloat()
90
+ }
91
+
92
+ private fun toneCurve(linear: Float): Float {
93
+ val idx = (linear * (DELIN_LUT_SIZE - 1) + 0.5f).toInt().coerceIn(0, DELIN_LUT_SIZE - 1)
94
+ return TONE_LUT_LINEAR[idx]
95
+ }
96
+
97
+ private fun toSrgbByte(linear: Float): Int {
98
+ val idx = (linear * (DELIN_LUT_SIZE - 1) + 0.5f).toInt().coerceIn(0, DELIN_LUT_SIZE - 1)
99
+ return DELINEARIZE_LUT[idx]
100
+ }
101
+
102
+ /**
103
+ * Stabilize a video using gyroscope data from an IMU sidecar file.
104
+ *
105
+ * @param inputPath Path to the input MP4 video
106
+ * @param imuPath Path to the IMU sidecar JSON file
107
+ * @param outputPath Path to write the stabilized MP4
108
+ * @return Processing time in milliseconds, or -1 on failure
109
+ */
110
+ @JvmStatic
111
+ fun stabilize(inputPath: String, imuPath: String, outputPath: String): Long {
112
+ val startTime = System.currentTimeMillis()
113
+
114
+ try {
115
+ // Parse IMU data
116
+ val imuSamples = parseImuData(imuPath)
117
+ if (imuSamples.isEmpty()) {
118
+ Log.w(TAG, "No IMU data available")
119
+ return -1
120
+ }
121
+ Log.d(TAG, "Loaded ${imuSamples.size} IMU samples")
122
+
123
+ val n = imuSamples.size
124
+
125
+ // Integrate gyro to get cumulative rotation (3 axes)
126
+ val cumRoll = DoubleArray(n) // gx
127
+ val cumPitch = DoubleArray(n) // gy
128
+ val cumYaw = DoubleArray(n) // gz
129
+
130
+ for (i in 1 until n) {
131
+ var dt = (imuSamples[i][0] - imuSamples[i - 1][0]) / 1000.0
132
+ if (dt <= 0 || dt > 0.1) dt = 0.01
133
+
134
+ cumRoll[i] = cumRoll[i - 1] + imuSamples[i][4] * dt // gx
135
+ cumPitch[i] = cumPitch[i - 1] + imuSamples[i][5] * dt // gy
136
+ cumYaw[i] = cumYaw[i - 1] + imuSamples[i][6] * dt // gz
137
+ }
138
+
139
+ // Smooth with multi-pass bidirectional EMA for aggressive stabilization
140
+ val smoothRoll = smoothEmaMultiPass(cumRoll)
141
+ val smoothPitch = smoothEmaMultiPass(cumPitch)
142
+ val smoothYaw = smoothEmaMultiPass(cumYaw)
143
+
144
+ // Correction = smooth - actual
145
+ val corrRoll = DoubleArray(n) { smoothRoll[it] - cumRoll[it] }
146
+ val corrPitch = DoubleArray(n) { smoothPitch[it] - cumPitch[it] }
147
+ val corrYaw = DoubleArray(n) { smoothYaw[it] - cumYaw[it] }
148
+
149
+ val imuDurationMs = imuSamples.last()[0]
150
+
151
+ // Setup video extractor
152
+ val videoExtractor = MediaExtractor().apply { setDataSource(inputPath) }
153
+ val videoTrackIdx = findTrack(videoExtractor, "video/")
154
+ if (videoTrackIdx < 0) {
155
+ Log.e(TAG, "No video track found")
156
+ videoExtractor.release()
157
+ return -1
158
+ }
159
+
160
+ videoExtractor.selectTrack(videoTrackIdx)
161
+ val videoFormat = videoExtractor.getTrackFormat(videoTrackIdx)
162
+ val width = videoFormat.getInteger(MediaFormat.KEY_WIDTH)
163
+ val height = videoFormat.getInteger(MediaFormat.KEY_HEIGHT)
164
+ val bitRate = if (videoFormat.containsKey(MediaFormat.KEY_BIT_RATE))
165
+ videoFormat.getInteger(MediaFormat.KEY_BIT_RATE)
166
+ else (width * height * 4.0).toInt()
167
+ val frameRate = if (videoFormat.containsKey(MediaFormat.KEY_FRAME_RATE))
168
+ videoFormat.getInteger(MediaFormat.KEY_FRAME_RATE)
169
+ else 30
170
+ val durationUs = if (videoFormat.containsKey(MediaFormat.KEY_DURATION))
171
+ videoFormat.getLong(MediaFormat.KEY_DURATION)
172
+ else 0L
173
+
174
+ Log.d(TAG, "Video ${width}x${height} fps=$frameRate duration=${durationUs / 1_000_000.0}s")
175
+
176
+ // Setup audio extractor
177
+ val audioExtractor = MediaExtractor().apply { setDataSource(inputPath) }
178
+ val audioTrackIdx = findTrack(audioExtractor, "audio/")
179
+
180
+ // Remove output if exists
181
+ File(outputPath).let { if (it.exists()) it.delete() }
182
+
183
+ // Setup muxer
184
+ val muxer = MediaMuxer(outputPath, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4)
185
+
186
+ // Setup decoder
187
+ val videoMime = videoFormat.getString(MediaFormat.KEY_MIME)!!
188
+ val decoder = MediaCodec.createDecoderByType(videoMime).apply {
189
+ videoFormat.setInteger(
190
+ MediaFormat.KEY_COLOR_FORMAT,
191
+ MediaCodecInfo.CodecCapabilities.COLOR_FormatYUV420Flexible
192
+ )
193
+ configure(videoFormat, null, null, 0)
194
+ start()
195
+ }
196
+
197
+ // Setup encoder
198
+ val encFormat = MediaFormat.createVideoFormat(MediaFormat.MIMETYPE_VIDEO_AVC, width, height).apply {
199
+ setInteger(MediaFormat.KEY_COLOR_FORMAT, MediaCodecInfo.CodecCapabilities.COLOR_FormatYUV420Flexible)
200
+ setInteger(MediaFormat.KEY_BIT_RATE, bitRate)
201
+ setInteger(MediaFormat.KEY_FRAME_RATE, frameRate)
202
+ setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, 1)
203
+ }
204
+ val encoder = MediaCodec.createEncoderByType(MediaFormat.MIMETYPE_VIDEO_AVC).apply {
205
+ configure(encFormat, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE)
206
+ start()
207
+ }
208
+
209
+ var muxVideoTrack = -1
210
+ var muxAudioTrack = -1
211
+ var muxerStarted = false
212
+ var decoderDone = false
213
+ var encoderDone = false
214
+ var inputDone = false
215
+ var frameCount = 0
216
+
217
+ val decInfo = MediaCodec.BufferInfo()
218
+ val encInfo = MediaCodec.BufferInfo()
219
+ val paint = Paint(Paint.FILTER_BITMAP_FLAG or Paint.ANTI_ALIAS_FLAG)
220
+
221
+ while (!encoderDone) {
222
+ // Feed decoder
223
+ if (!inputDone) {
224
+ val inIdx = decoder.dequeueInputBuffer(CODEC_TIMEOUT_US)
225
+ if (inIdx >= 0) {
226
+ val inBuf = decoder.getInputBuffer(inIdx)!!
227
+ val sampleSize = videoExtractor.readSampleData(inBuf, 0)
228
+ if (sampleSize < 0) {
229
+ decoder.queueInputBuffer(inIdx, 0, 0, 0, MediaCodec.BUFFER_FLAG_END_OF_STREAM)
230
+ inputDone = true
231
+ } else {
232
+ val pts = videoExtractor.sampleTime
233
+ decoder.queueInputBuffer(inIdx, 0, sampleSize, pts, 0)
234
+ videoExtractor.advance()
235
+ }
236
+ }
237
+ }
238
+
239
+ // Drain decoder → transform → feed encoder
240
+ if (!decoderDone) {
241
+ val outIdx = decoder.dequeueOutputBuffer(decInfo, CODEC_TIMEOUT_US)
242
+ if (outIdx >= 0) {
243
+ if (decInfo.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0) {
244
+ decoderDone = true
245
+ decoder.releaseOutputBuffer(outIdx, false)
246
+ // Signal encoder EOS
247
+ val encInIdx = encoder.dequeueInputBuffer(CODEC_TIMEOUT_US)
248
+ if (encInIdx >= 0) {
249
+ encoder.queueInputBuffer(encInIdx, 0, 0, 0, MediaCodec.BUFFER_FLAG_END_OF_STREAM)
250
+ }
251
+ } else {
252
+ val image = decoder.getOutputImage(outIdx)
253
+ if (image != null) {
254
+ val srcBitmap = yuvImageToBitmap(image, width, height)
255
+ image.close()
256
+ decoder.releaseOutputBuffer(outIdx, false)
257
+
258
+ // Find IMU correction for this frame
259
+ val frameTimeMs = decInfo.presentationTimeUs / 1000.0
260
+ val ratio = if (imuDurationMs > 0) frameTimeMs / imuDurationMs else 0.0
261
+ val imuIdx = max(0, min((ratio * (n - 1)).toInt(), n - 1))
262
+
263
+ val roll = corrRoll[imuIdx]
264
+ val pitch = corrPitch[imuIdx]
265
+ val yaw = corrYaw[imuIdx]
266
+
267
+ // Always apply crop+scale for consistent framing, plus stabilization correction
268
+ val dst = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
269
+ val canvas = Canvas(dst)
270
+ val cx = width / 2f
271
+ val cy = height / 2f
272
+
273
+ // Scale factor to zoom in and hide black edges from stabilization
274
+ val scale = 1.0f / (1.0f - 2.0f * CROP_MARGIN.toFloat())
275
+
276
+ // Clamp corrections to the crop margin so we never show black edges
277
+ val maxShiftX = CROP_MARGIN * width
278
+ val maxShiftY = CROP_MARGIN * height
279
+ val maxRollRad = CROP_MARGIN * 0.5 // conservative limit for rotation
280
+
281
+ val clampedRoll = roll.coerceIn(-maxRollRad, maxRollRad)
282
+ val clampedPitchShift = (-pitch * cx).toFloat().coerceIn(-maxShiftX.toFloat(), maxShiftX.toFloat())
283
+ val clampedYawShift = (yaw * cy).toFloat().coerceIn(-maxShiftY.toFloat(), maxShiftY.toFloat())
284
+
285
+ val matrix = Matrix().apply {
286
+ postTranslate(-cx, -cy)
287
+ // Crop zoom
288
+ postScale(scale, scale)
289
+ // Roll: Z-axis rotation
290
+ postRotate(Math.toDegrees(-clampedRoll).toFloat())
291
+ // Pitch: horizontal shift
292
+ postTranslate(clampedPitchShift, 0f)
293
+ // Yaw: vertical shift
294
+ postTranslate(0f, clampedYawShift)
295
+ postTranslate(cx, cy)
296
+ }
297
+
298
+ canvas.drawBitmap(srcBitmap, matrix, paint)
299
+ srcBitmap.recycle()
300
+ val outBitmap = dst
301
+
302
+ feedBitmapToEncoder(encoder, outBitmap, decInfo.presentationTimeUs, width, height)
303
+ outBitmap.recycle()
304
+ frameCount++
305
+ } else {
306
+ decoder.releaseOutputBuffer(outIdx, false)
307
+ }
308
+ }
309
+ }
310
+ }
311
+
312
+ // Drain encoder output
313
+ val encOutIdx = encoder.dequeueOutputBuffer(encInfo, CODEC_TIMEOUT_US)
314
+ when {
315
+ encOutIdx == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED -> {
316
+ if (!muxerStarted) {
317
+ muxVideoTrack = muxer.addTrack(encoder.outputFormat)
318
+ if (audioTrackIdx >= 0) {
319
+ audioExtractor.selectTrack(audioTrackIdx)
320
+ muxAudioTrack = muxer.addTrack(audioExtractor.getTrackFormat(audioTrackIdx))
321
+ }
322
+ muxer.start()
323
+ muxerStarted = true
324
+ }
325
+ }
326
+ encOutIdx >= 0 -> {
327
+ val encBuf = encoder.getOutputBuffer(encOutIdx)!!
328
+ if (encInfo.size > 0 && muxerStarted) {
329
+ encBuf.position(encInfo.offset)
330
+ encBuf.limit(encInfo.offset + encInfo.size)
331
+ muxer.writeSampleData(muxVideoTrack, encBuf, encInfo)
332
+ }
333
+ encoder.releaseOutputBuffer(encOutIdx, false)
334
+ if (encInfo.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0) {
335
+ encoderDone = true
336
+ }
337
+ }
338
+ }
339
+ }
340
+
341
+ // Copy audio track
342
+ if (muxerStarted && muxAudioTrack >= 0) {
343
+ val audioBuf = ByteBuffer.allocate(1024 * 1024)
344
+ val audioInfo = MediaCodec.BufferInfo()
345
+ while (true) {
346
+ val size = audioExtractor.readSampleData(audioBuf, 0)
347
+ if (size < 0) break
348
+ audioInfo.offset = 0
349
+ audioInfo.size = size
350
+ audioInfo.presentationTimeUs = audioExtractor.sampleTime
351
+ audioInfo.flags = audioExtractor.sampleFlags
352
+ muxer.writeSampleData(muxAudioTrack, audioBuf, audioInfo)
353
+ audioExtractor.advance()
354
+ }
355
+ }
356
+
357
+ // Cleanup
358
+ decoder.stop(); decoder.release()
359
+ encoder.stop(); encoder.release()
360
+ videoExtractor.release()
361
+ audioExtractor.release()
362
+ if (muxerStarted) muxer.stop()
363
+ muxer.release()
364
+
365
+ val elapsed = System.currentTimeMillis() - startTime
366
+ Log.d(TAG, "Stabilization complete: $frameCount frames in ${elapsed}ms")
367
+ return elapsed
368
+
369
+ } catch (e: Exception) {
370
+ Log.e(TAG, "Video stabilization failed", e)
371
+ try { File(outputPath).delete() } catch (_: Exception) {}
372
+ return -1
373
+ }
374
+ }
375
+
376
+ // -- Private helpers --
377
+
378
+ /** Feed a Bitmap to the encoder by converting ARGB → YUV420. */
379
+ private fun feedBitmapToEncoder(
380
+ encoder: MediaCodec, bitmap: Bitmap, presentationTimeUs: Long, width: Int, height: Int
381
+ ) {
382
+ var inIdx = -1
383
+ while (inIdx < 0) {
384
+ inIdx = encoder.dequeueInputBuffer(CODEC_TIMEOUT_US)
385
+ if (inIdx < 0) drainEncoderSilently(encoder)
386
+ }
387
+
388
+ encoder.getInputImage(inIdx)?.let { image ->
389
+ bitmapToYuv420(bitmap, image)
390
+ image.close()
391
+ }
392
+
393
+ encoder.queueInputBuffer(inIdx, 0, width * height * 3 / 2, presentationTimeUs, 0)
394
+ }
395
+
396
+ /** Convert ARGB_8888 Bitmap to YUV420 Image planes. */
397
+ private fun bitmapToYuv420(bitmap: Bitmap, image: Image) {
398
+ val w = bitmap.width
399
+ val h = bitmap.height
400
+ val pixels = IntArray(w * h)
401
+ bitmap.getPixels(pixels, 0, w, 0, 0, w, h)
402
+
403
+ val yPlane = image.planes[0]
404
+ val uPlane = image.planes[1]
405
+ val vPlane = image.planes[2]
406
+ val yBuf = yPlane.buffer
407
+ val uBuf = uPlane.buffer
408
+ val vBuf = vPlane.buffer
409
+ val yRowStride = yPlane.rowStride
410
+ val uvRowStride = uPlane.rowStride
411
+ val uvPixelStride = uPlane.pixelStride
412
+
413
+ for (y in 0 until h) {
414
+ for (x in 0 until w) {
415
+ val argb = pixels[y * w + x]
416
+
417
+ // 1. Linearize sRGB → linear
418
+ var lr = LINEARIZE_LUT[(argb shr 16) and 0xFF]
419
+ var lg = LINEARIZE_LUT[(argb shr 8) and 0xFF]
420
+ var lb = LINEARIZE_LUT[argb and 0xFF]
421
+
422
+ // 2. Tone curve in linear space
423
+ lr = toneCurve(lr)
424
+ lg = toneCurve(lg)
425
+ lb = toneCurve(lb)
426
+
427
+ // 3. Vibrance — luminance-based, stays in linear space (no HSV round-trip)
428
+ val lum = 0.2126f * lr + 0.7152f * lg + 0.0722f * lb
429
+ val maxC = maxOf(lr, lg, lb)
430
+ val minC = minOf(lr, lg, lb)
431
+ val sat = if (maxC > 0.0001f) (maxC - minC) / maxC else 0f
432
+ val boost = VIBRANCE_AMOUNT * (1.0f - sat)
433
+ lr += (lr - lum) * boost
434
+ lg += (lg - lum) * boost
435
+ lb += (lb - lum) * boost
436
+
437
+ // 4. Color correction matrix in linear space
438
+ val clr = (CM_RR * lr + CM_RG * lg + CM_RB * lb + CM_R_BIAS).coerceIn(0f, 1f)
439
+ val clg = (CM_GR * lr + CM_GG * lg + CM_GB * lb + CM_G_BIAS).coerceIn(0f, 1f)
440
+ val clb = (CM_BR * lr + CM_BG * lg + CM_BB * lb + CM_B_BIAS).coerceIn(0f, 1f)
441
+
442
+ // 5. Encode linear → sRGB
443
+ val cr = toSrgbByte(clr)
444
+ val cg = toSrgbByte(clg)
445
+ val cb = toSrgbByte(clb)
446
+
447
+ // BT.601 RGB → YUV
448
+ val yVal = ((66 * cr + 129 * cg + 25 * cb + 128) shr 8) + 16
449
+ yBuf.put(y * yRowStride + x, yVal.coerceIn(0, 255).toByte())
450
+
451
+ if (y % 2 == 0 && x % 2 == 0) {
452
+ val uVal = ((-38 * cr - 74 * cg + 112 * cb + 128) shr 8) + 128
453
+ val vVal = ((112 * cr - 94 * cg - 18 * cb + 128) shr 8) + 128
454
+ val uvIdx = (y / 2) * uvRowStride + (x / 2) * uvPixelStride
455
+ uBuf.put(uvIdx, uVal.coerceIn(0, 255).toByte())
456
+ vBuf.put(uvIdx, vVal.coerceIn(0, 255).toByte())
457
+ }
458
+ }
459
+ }
460
+ }
461
+
462
+ /** Drain encoder output without writing (used when waiting for input buffer). */
463
+ private fun drainEncoderSilently(encoder: MediaCodec) {
464
+ val info = MediaCodec.BufferInfo()
465
+ val idx = encoder.dequeueOutputBuffer(info, CODEC_TIMEOUT_US)
466
+ if (idx >= 0) encoder.releaseOutputBuffer(idx, false)
467
+ }
468
+
469
+ /** Convert a decoded YUV420 Image to ARGB_8888 Bitmap. */
470
+ private fun yuvImageToBitmap(image: Image, width: Int, height: Int): Bitmap {
471
+ val yPlane = image.planes[0]
472
+ val uPlane = image.planes[1]
473
+ val vPlane = image.planes[2]
474
+ val yBuf = yPlane.buffer
475
+ val uBuf = uPlane.buffer
476
+ val vBuf = vPlane.buffer
477
+ val yRowStride = yPlane.rowStride
478
+ val uvRowStride = uPlane.rowStride
479
+ val uvPixelStride = uPlane.pixelStride
480
+
481
+ val pixels = IntArray(width * height)
482
+
483
+ for (y in 0 until height) {
484
+ for (x in 0 until width) {
485
+ val yy = (yBuf.get(y * yRowStride + x).toInt() and 0xFF) - 16
486
+ val uvIdx = (y / 2) * uvRowStride + (x / 2) * uvPixelStride
487
+ val uu = (uBuf.get(uvIdx).toInt() and 0xFF) - 128
488
+ val vv = (vBuf.get(uvIdx).toInt() and 0xFF) - 128
489
+
490
+ // BT.601 YUV → RGB
491
+ val r = ((298 * yy + 409 * vv + 128) shr 8).coerceIn(0, 255)
492
+ val g = ((298 * yy - 100 * uu - 208 * vv + 128) shr 8).coerceIn(0, 255)
493
+ val b = ((298 * yy + 516 * uu + 128) shr 8).coerceIn(0, 255)
494
+
495
+ pixels[y * width + x] = (0xFF shl 24) or (r shl 16) or (g shl 8) or b
496
+ }
497
+ }
498
+
499
+ return Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888).apply {
500
+ setPixels(pixels, 0, width, 0, 0, width, height)
501
+ }
502
+ }
503
+
504
+ /** Find a track in a MediaExtractor by MIME type prefix. */
505
+ private fun findTrack(extractor: MediaExtractor, mimePrefix: String): Int {
506
+ for (i in 0 until extractor.trackCount) {
507
+ val mime = extractor.getTrackFormat(i).getString(MediaFormat.KEY_MIME)
508
+ if (mime?.startsWith(mimePrefix) == true) return i
509
+ }
510
+ return -1
511
+ }
512
+
513
+ /** Parse IMU sidecar JSON into list of [timeMs, ax, ay, az, gx, gy, gz]. */
514
+ private fun parseImuData(path: String): List<DoubleArray> {
515
+ return try {
516
+ val json = JSONObject(File(path).readText())
517
+ val samples = json.getJSONArray("samples")
518
+ (0 until samples.length()).mapNotNull { i ->
519
+ val s = samples.getJSONArray(i)
520
+ if (s.length() < 7) return@mapNotNull null
521
+ doubleArrayOf(
522
+ s.getDouble(0), // timeMs
523
+ s.getDouble(1), // ax
524
+ s.getDouble(2), // ay
525
+ s.getDouble(3), // az
526
+ s.getDouble(4), // gx
527
+ s.getDouble(5), // gy
528
+ s.getDouble(6), // gz
529
+ )
530
+ }
531
+ } catch (e: Exception) {
532
+ Log.e(TAG, "Failed to parse IMU data", e)
533
+ emptyList()
534
+ }
535
+ }
536
+
537
+ /** Multi-pass bidirectional EMA smoothing for aggressive stabilization. */
538
+ private fun smoothEmaMultiPass(data: DoubleArray): DoubleArray {
539
+ if (data.isEmpty()) return data
540
+ var result = data.copyOf()
541
+ repeat(SMOOTH_PASSES) {
542
+ val smooth = DoubleArray(result.size)
543
+ smooth[0] = result[0]
544
+ // Forward pass
545
+ for (i in 1 until result.size) {
546
+ smooth[i] = SMOOTH_FACTOR * smooth[i - 1] + (1 - SMOOTH_FACTOR) * result[i]
547
+ }
548
+ // Backward pass
549
+ for (i in result.size - 2 downTo 0) {
550
+ smooth[i] = SMOOTH_FACTOR * smooth[i + 1] + (1 - SMOOTH_FACTOR) * smooth[i]
551
+ }
552
+ result = smooth
553
+ }
554
+ return result
555
+ }
556
+ }
@@ -0,0 +1,3 @@
1
+ <resources>
2
+ <string name="mentra_crust_notification_listener_label">Mentra</string>
3
+ </resources>
package/app.plugin.js ADDED
@@ -0,0 +1,3 @@
1
+ const plugin = require("./plugin/build")
2
+
3
+ module.exports = plugin.default ?? plugin