@mentra/crust 0.1.0-dev.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +35 -0
- package/android/build.gradle +146 -0
- package/android/src/internal/AndroidManifest.xml +9 -0
- package/android/src/internal/java/com/mentra/crust/receivers/CaptionsTesterIncidentReceiver.kt +46 -0
- package/android/src/main/AndroidManifest.xml +19 -0
- package/android/src/main/java/com/mentra/crust/CrustModule.kt +882 -0
- package/android/src/main/java/com/mentra/crust/CrustView.kt +30 -0
- package/android/src/main/java/com/mentra/crust/heading/HeadingManager.kt +150 -0
- package/android/src/main/java/com/mentra/crust/jsc/JSCDispatcher.kt +189 -0
- package/android/src/main/java/com/mentra/crust/jsc/JSCPolyfillBridge.kt +246 -0
- package/android/src/main/java/com/mentra/crust/jsc/JSCRuntime.kt +593 -0
- package/android/src/main/java/com/mentra/crust/navigation/NavigationManager.kt +1445 -0
- package/android/src/main/java/com/mentra/crust/services/NotificationListener.kt +319 -0
- package/android/src/main/java/com/mentra/crust/utils/ImageProcessor.java +452 -0
- package/android/src/main/java/com/mentra/crust/utils/VideoStabilizer.kt +556 -0
- package/android/src/main/res/values/strings.xml +3 -0
- package/app.plugin.js +3 -0
- package/build/Crust.types.d.ts +148 -0
- package/build/Crust.types.d.ts.map +1 -0
- package/build/Crust.types.js +2 -0
- package/build/Crust.types.js.map +1 -0
- package/build/CrustModule.d.ts +175 -0
- package/build/CrustModule.d.ts.map +1 -0
- package/build/CrustModule.js +4 -0
- package/build/CrustModule.js.map +1 -0
- package/build/CrustModule.web.d.ts +26 -0
- package/build/CrustModule.web.d.ts.map +1 -0
- package/build/CrustModule.web.js +54 -0
- package/build/CrustModule.web.js.map +1 -0
- package/build/CrustView.d.ts +4 -0
- package/build/CrustView.d.ts.map +1 -0
- package/build/CrustView.js +7 -0
- package/build/CrustView.js.map +1 -0
- package/build/CrustView.web.d.ts +4 -0
- package/build/CrustView.web.d.ts.map +1 -0
- package/build/CrustView.web.js +7 -0
- package/build/CrustView.web.js.map +1 -0
- package/build/index.d.ts +4 -0
- package/build/index.d.ts.map +1 -0
- package/build/index.js +6 -0
- package/build/index.js.map +1 -0
- package/expo-module.config.json +9 -0
- package/ios/Crust.podspec +65 -0
- package/ios/CrustModule.swift +544 -0
- package/ios/CrustView.swift +38 -0
- package/ios/Resources/startup.js +814 -0
- package/ios/Source/JSCDispatcher.swift +226 -0
- package/ios/Source/JSCPolyfillBridge.swift +378 -0
- package/ios/Source/JSCRuntime.swift +673 -0
- package/ios/Source/utils/ImageProcessor.swift +392 -0
- package/ios/Source/utils/SystemGestures.swift +53 -0
- package/ios/Source/utils/VideoStabilizer.swift +374 -0
- package/ios/heading/HeadingManager.swift +74 -0
- package/ios/navigation/NavPayloads.swift +62 -0
- package/ios/navigation/NavigationManager.swift +720 -0
- package/package.json +69 -0
- package/plugin/build/index.d.ts +19 -0
- package/plugin/build/index.js +23 -0
- package/plugin/build/withAndroid.d.ts +2 -0
- package/plugin/build/withAndroid.js +78 -0
- package/src/Crust.types.ts +157 -0
- package/src/CrustModule.ts +186 -0
- package/src/CrustModule.web.ts +57 -0
- package/src/CrustView.tsx +10 -0
- package/src/CrustView.web.tsx +11 -0
- 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
|
+
}
|
package/app.plugin.js
ADDED