@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,374 @@
|
|
|
1
|
+
import AVFoundation
|
|
2
|
+
import CoreImage
|
|
3
|
+
import Foundation
|
|
4
|
+
|
|
5
|
+
/// Gyroscope-based video stabilizer.
|
|
6
|
+
/// Uses IMU sidecar data to apply motion-compensated frame warping,
|
|
7
|
+
/// correcting rotation jitter in videos.
|
|
8
|
+
///
|
|
9
|
+
/// Phase 1: rotation correction (pan/tilt/roll) using gyro integration
|
|
10
|
+
/// + exponential moving average smoothing.
|
|
11
|
+
class VideoStabilizer {
|
|
12
|
+
private static let TAG = "VideoStabilizer"
|
|
13
|
+
private static let SMOOTH_FACTOR = 0.98
|
|
14
|
+
private static let SMOOTH_PASSES = 3
|
|
15
|
+
private static let CROP_MARGIN = 0.08
|
|
16
|
+
|
|
17
|
+
struct ImuSample {
|
|
18
|
+
let timeMs: Double
|
|
19
|
+
let ax: Double
|
|
20
|
+
let ay: Double
|
|
21
|
+
let az: Double
|
|
22
|
+
let gx: Double
|
|
23
|
+
let gy: Double
|
|
24
|
+
let gz: Double
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/// Stabilize a video using IMU sidecar data.
|
|
28
|
+
/// - Parameters:
|
|
29
|
+
/// - inputPath: Path to the input MP4 video
|
|
30
|
+
/// - imuPath: Path to the IMU sidecar JSON file
|
|
31
|
+
/// - outputPath: Path to write the stabilized MP4
|
|
32
|
+
/// - Returns: Processing time in milliseconds, or -1 on failure
|
|
33
|
+
static func stabilize(inputPath: String, imuPath: String, outputPath: String) -> Int64 {
|
|
34
|
+
let startTime = CFAbsoluteTimeGetCurrent()
|
|
35
|
+
|
|
36
|
+
// Parse IMU data
|
|
37
|
+
guard let imuSamples = parseImuData(imuPath), !imuSamples.isEmpty else {
|
|
38
|
+
NSLog("\(TAG): No IMU data available")
|
|
39
|
+
return -1
|
|
40
|
+
}
|
|
41
|
+
NSLog("\(TAG): Loaded \(imuSamples.count) IMU samples")
|
|
42
|
+
|
|
43
|
+
// Integrate gyro data to get cumulative rotation (3 axes)
|
|
44
|
+
var cumulativeRoll = [Double](repeating: 0, count: imuSamples.count)
|
|
45
|
+
var cumulativePitch = [Double](repeating: 0, count: imuSamples.count)
|
|
46
|
+
var cumulativeYaw = [Double](repeating: 0, count: imuSamples.count)
|
|
47
|
+
|
|
48
|
+
for i in 1 ..< imuSamples.count {
|
|
49
|
+
var dt = (imuSamples[i].timeMs - imuSamples[i - 1].timeMs) / 1000.0
|
|
50
|
+
if dt <= 0 || dt > 0.1 { dt = 0.01 }
|
|
51
|
+
|
|
52
|
+
cumulativeRoll[i] = cumulativeRoll[i - 1] + imuSamples[i].gx * dt
|
|
53
|
+
cumulativePitch[i] = cumulativePitch[i - 1] + imuSamples[i].gy * dt
|
|
54
|
+
cumulativeYaw[i] = cumulativeYaw[i - 1] + imuSamples[i].gz * dt
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Smooth with multi-pass bidirectional EMA for aggressive stabilization
|
|
58
|
+
let smoothRoll = smoothEmaMultiPass(cumulativeRoll)
|
|
59
|
+
let smoothPitch = smoothEmaMultiPass(cumulativePitch)
|
|
60
|
+
let smoothYaw = smoothEmaMultiPass(cumulativeYaw)
|
|
61
|
+
|
|
62
|
+
// Correction = smooth - actual
|
|
63
|
+
var corrRoll = [Double](repeating: 0, count: imuSamples.count)
|
|
64
|
+
var corrPitch = [Double](repeating: 0, count: imuSamples.count)
|
|
65
|
+
var corrYaw = [Double](repeating: 0, count: imuSamples.count)
|
|
66
|
+
for i in 0 ..< imuSamples.count {
|
|
67
|
+
corrRoll[i] = smoothRoll[i] - cumulativeRoll[i]
|
|
68
|
+
corrPitch[i] = smoothPitch[i] - cumulativePitch[i]
|
|
69
|
+
corrYaw[i] = smoothYaw[i] - cumulativeYaw[i]
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Setup AVAsset reader/writer pipeline
|
|
73
|
+
let inputURL = URL(fileURLWithPath: inputPath)
|
|
74
|
+
let outputURL = URL(fileURLWithPath: outputPath)
|
|
75
|
+
|
|
76
|
+
// Remove output if it exists
|
|
77
|
+
try? FileManager.default.removeItem(at: outputURL)
|
|
78
|
+
|
|
79
|
+
let asset = AVAsset(url: inputURL)
|
|
80
|
+
|
|
81
|
+
guard let videoTrack = asset.tracks(withMediaType: .video).first else {
|
|
82
|
+
NSLog("\(TAG): No video track found")
|
|
83
|
+
return -1
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
let videoSize = videoTrack.naturalSize
|
|
87
|
+
let frameRate = videoTrack.nominalFrameRate
|
|
88
|
+
let videoDuration = CMTimeGetSeconds(asset.duration)
|
|
89
|
+
let imuDurationMs = imuSamples.last?.timeMs ?? 1.0
|
|
90
|
+
|
|
91
|
+
NSLog(
|
|
92
|
+
"\(TAG): Video \(Int(videoSize.width))x\(Int(videoSize.height)) fps=\(frameRate) duration=\(videoDuration)s"
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
// Setup reader
|
|
96
|
+
guard let reader = try? AVAssetReader(asset: asset) else {
|
|
97
|
+
NSLog("\(TAG): Failed to create AVAssetReader")
|
|
98
|
+
return -1
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
let readerSettings: [String: Any] = [
|
|
102
|
+
kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32BGRA,
|
|
103
|
+
]
|
|
104
|
+
let readerOutput = AVAssetReaderTrackOutput(track: videoTrack, outputSettings: readerSettings)
|
|
105
|
+
reader.add(readerOutput)
|
|
106
|
+
|
|
107
|
+
// Setup writer
|
|
108
|
+
guard let writer = try? AVAssetWriter(outputURL: outputURL, fileType: .mp4) else {
|
|
109
|
+
NSLog("\(TAG): Failed to create AVAssetWriter")
|
|
110
|
+
return -1
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
let writerSettings: [String: Any] = [
|
|
114
|
+
AVVideoCodecKey: AVVideoCodecType.h264,
|
|
115
|
+
AVVideoWidthKey: Int(videoSize.width),
|
|
116
|
+
AVVideoHeightKey: Int(videoSize.height),
|
|
117
|
+
AVVideoCompressionPropertiesKey: [
|
|
118
|
+
AVVideoAverageBitRateKey: Int(videoTrack.estimatedDataRate),
|
|
119
|
+
AVVideoMaxKeyFrameIntervalKey: 30,
|
|
120
|
+
],
|
|
121
|
+
]
|
|
122
|
+
let videoFormatHint = videoTrack.formatDescriptions.first.map { $0 as! CMFormatDescription }
|
|
123
|
+
let writerInput = AVAssetWriterInput(
|
|
124
|
+
mediaType: .video, outputSettings: writerSettings, sourceFormatHint: videoFormatHint
|
|
125
|
+
)
|
|
126
|
+
writerInput.transform = videoTrack.preferredTransform
|
|
127
|
+
|
|
128
|
+
let adaptor = AVAssetWriterInputPixelBufferAdaptor(
|
|
129
|
+
assetWriterInput: writerInput,
|
|
130
|
+
sourcePixelBufferAttributes: [
|
|
131
|
+
kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32BGRA,
|
|
132
|
+
kCVPixelBufferWidthKey as String: Int(videoSize.width),
|
|
133
|
+
kCVPixelBufferHeightKey as String: Int(videoSize.height),
|
|
134
|
+
]
|
|
135
|
+
)
|
|
136
|
+
writer.add(writerInput)
|
|
137
|
+
|
|
138
|
+
// Copy audio track if present
|
|
139
|
+
var audioWriterInput: AVAssetWriterInput?
|
|
140
|
+
var audioReaderOutput: AVAssetReaderTrackOutput?
|
|
141
|
+
if let audioTrack = asset.tracks(withMediaType: .audio).first {
|
|
142
|
+
let audioOutput = AVAssetReaderTrackOutput(track: audioTrack, outputSettings: nil)
|
|
143
|
+
reader.add(audioOutput)
|
|
144
|
+
audioReaderOutput = audioOutput
|
|
145
|
+
|
|
146
|
+
let audioFormatHint = audioTrack.formatDescriptions.first.map { $0 as! CMFormatDescription }
|
|
147
|
+
let audioInput = AVAssetWriterInput(
|
|
148
|
+
mediaType: .audio, outputSettings: nil, sourceFormatHint: audioFormatHint
|
|
149
|
+
)
|
|
150
|
+
writer.add(audioInput)
|
|
151
|
+
audioWriterInput = audioInput
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Start processing
|
|
155
|
+
reader.startReading()
|
|
156
|
+
writer.startWriting()
|
|
157
|
+
writer.startSession(atSourceTime: .zero)
|
|
158
|
+
|
|
159
|
+
let ciContext = CIContext(options: [.useSoftwareRenderer: false])
|
|
160
|
+
var frameCount = 0
|
|
161
|
+
|
|
162
|
+
// Process video frames
|
|
163
|
+
let videoGroup = DispatchGroup()
|
|
164
|
+
videoGroup.enter()
|
|
165
|
+
|
|
166
|
+
writerInput.requestMediaDataWhenReady(on: DispatchQueue(label: "videoStabilizer.video")) {
|
|
167
|
+
while writerInput.isReadyForMoreMediaData {
|
|
168
|
+
guard let sampleBuffer = readerOutput.copyNextSampleBuffer() else {
|
|
169
|
+
writerInput.markAsFinished()
|
|
170
|
+
videoGroup.leave()
|
|
171
|
+
return
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// autoreleasepool prevents CIImage/CIFilter intermediates from accumulating
|
|
175
|
+
// in the tight while loop, which otherwise causes OOM crashes on longer videos.
|
|
176
|
+
autoreleasepool {
|
|
177
|
+
let presentationTime = CMSampleBufferGetPresentationTimeStamp(sampleBuffer)
|
|
178
|
+
let frameTimeMs = CMTimeGetSeconds(presentationTime) * 1000.0
|
|
179
|
+
|
|
180
|
+
// Find correction for this frame
|
|
181
|
+
let ratio = imuDurationMs > 0 ? frameTimeMs / imuDurationMs : 0
|
|
182
|
+
let imuIdx = max(0, min(Int(ratio * Double(imuSamples.count - 1)), imuSamples.count - 1))
|
|
183
|
+
|
|
184
|
+
let rollCorr = corrRoll[imuIdx]
|
|
185
|
+
let pitchCorr = corrPitch[imuIdx]
|
|
186
|
+
let yawCorr = corrYaw[imuIdx]
|
|
187
|
+
|
|
188
|
+
guard let pixelBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) else { return }
|
|
189
|
+
var ciImage = CIImage(cvPixelBuffer: pixelBuffer)
|
|
190
|
+
|
|
191
|
+
// Apply color corrections (same pipeline as ImageProcessor)
|
|
192
|
+
ciImage = applyToneCurve(ciImage)
|
|
193
|
+
ciImage = applyVibrance(ciImage)
|
|
194
|
+
ciImage = applyColorCorrection(ciImage)
|
|
195
|
+
|
|
196
|
+
// Always apply crop+scale for consistent framing, plus stabilization correction
|
|
197
|
+
let cx = videoSize.width / 2
|
|
198
|
+
let cy = videoSize.height / 2
|
|
199
|
+
let scale = 1.0 / (1.0 - 2.0 * CROP_MARGIN)
|
|
200
|
+
|
|
201
|
+
// Clamp corrections to the crop margin so we never show black edges
|
|
202
|
+
let maxShiftX = CROP_MARGIN * Double(videoSize.width)
|
|
203
|
+
let maxShiftY = CROP_MARGIN * Double(videoSize.height)
|
|
204
|
+
let maxRollRad = CROP_MARGIN * 0.5
|
|
205
|
+
|
|
206
|
+
let clampedRoll = min(max(rollCorr, -maxRollRad), maxRollRad)
|
|
207
|
+
let clampedPitchShift = min(max(-pitchCorr * Double(cx), -maxShiftX), maxShiftX)
|
|
208
|
+
let clampedYawShift = min(max(yawCorr * Double(cy), -maxShiftY), maxShiftY)
|
|
209
|
+
|
|
210
|
+
// Build transform: center → scale → rotate → translate → uncenter
|
|
211
|
+
let transform = CGAffineTransform.identity
|
|
212
|
+
.translatedBy(x: cx, y: cy)
|
|
213
|
+
.scaledBy(x: CGFloat(scale), y: CGFloat(scale))
|
|
214
|
+
.rotated(by: CGFloat(-clampedRoll))
|
|
215
|
+
.translatedBy(x: CGFloat(clampedPitchShift), y: CGFloat(clampedYawShift))
|
|
216
|
+
.translatedBy(x: -cx, y: -cy)
|
|
217
|
+
|
|
218
|
+
ciImage = ciImage.transformed(by: transform)
|
|
219
|
+
|
|
220
|
+
// Crop back to original size and reset origin to (0,0)
|
|
221
|
+
// Without the origin reset, CIContext.render offsets into the pixel buffer,
|
|
222
|
+
// leaving black strips on the right/bottom edges.
|
|
223
|
+
let cropRect = CGRect(
|
|
224
|
+
x: ciImage.extent.origin.x + (ciImage.extent.width - videoSize.width) / 2,
|
|
225
|
+
y: ciImage.extent.origin.y + (ciImage.extent.height - videoSize.height) / 2,
|
|
226
|
+
width: videoSize.width,
|
|
227
|
+
height: videoSize.height
|
|
228
|
+
)
|
|
229
|
+
ciImage = ciImage.cropped(to: cropRect)
|
|
230
|
+
.transformed(by: CGAffineTransform(translationX: -cropRect.origin.x, y: -cropRect.origin.y))
|
|
231
|
+
|
|
232
|
+
// Render to pixel buffer
|
|
233
|
+
if let pool = adaptor.pixelBufferPool {
|
|
234
|
+
var outputBuffer: CVPixelBuffer?
|
|
235
|
+
CVPixelBufferPoolCreatePixelBuffer(nil, pool, &outputBuffer)
|
|
236
|
+
if let outBuf = outputBuffer {
|
|
237
|
+
ciContext.render(ciImage, to: outBuf)
|
|
238
|
+
adaptor.append(outBuf, withPresentationTime: presentationTime)
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
frameCount += 1
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Process audio
|
|
248
|
+
if let audioInput = audioWriterInput, let audioOutput = audioReaderOutput {
|
|
249
|
+
let audioGroup = DispatchGroup()
|
|
250
|
+
audioGroup.enter()
|
|
251
|
+
|
|
252
|
+
audioInput.requestMediaDataWhenReady(on: DispatchQueue(label: "videoStabilizer.audio")) {
|
|
253
|
+
while audioInput.isReadyForMoreMediaData {
|
|
254
|
+
guard let sampleBuffer = audioOutput.copyNextSampleBuffer() else {
|
|
255
|
+
audioInput.markAsFinished()
|
|
256
|
+
audioGroup.leave()
|
|
257
|
+
return
|
|
258
|
+
}
|
|
259
|
+
audioInput.append(sampleBuffer)
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
audioGroup.wait()
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
videoGroup.wait()
|
|
266
|
+
|
|
267
|
+
// Finish writing
|
|
268
|
+
let semaphore = DispatchSemaphore(value: 0)
|
|
269
|
+
writer.finishWriting {
|
|
270
|
+
semaphore.signal()
|
|
271
|
+
}
|
|
272
|
+
semaphore.wait()
|
|
273
|
+
|
|
274
|
+
let elapsed = Int64((CFAbsoluteTimeGetCurrent() - startTime) * 1000)
|
|
275
|
+
NSLog("\(TAG): Stabilization complete: \(frameCount) frames in \(elapsed)ms")
|
|
276
|
+
return elapsed
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// MARK: - Color Pipeline Tuning Parameters
|
|
280
|
+
|
|
281
|
+
// Tone curve anchor points (CIToneCurve: x = input, y = output)
|
|
282
|
+
private static let toneCurvePoint0 = CIVector(x: 0.00, y: 0.05)
|
|
283
|
+
private static let toneCurvePoint1 = CIVector(x: 0.25, y: 0.22)
|
|
284
|
+
private static let toneCurvePoint2 = CIVector(x: 0.50, y: 0.50)
|
|
285
|
+
private static let toneCurvePoint3 = CIVector(x: 0.75, y: 0.78)
|
|
286
|
+
private static let toneCurvePoint4 = CIVector(x: 1.00, y: 0.95)
|
|
287
|
+
|
|
288
|
+
// Vibrance: selective saturation boost (0.0 = off, 1.0 = max)
|
|
289
|
+
private static let vibranceAmount: Double = 0.3
|
|
290
|
+
|
|
291
|
+
// Color correction matrix (CIColorMatrix vectors)
|
|
292
|
+
private static let rVector = CIVector(x: 1.06, y: 0.02, z: -0.01, w: 0)
|
|
293
|
+
private static let gVector = CIVector(x: 0.01, y: 1.04, z: -0.01, w: 0)
|
|
294
|
+
private static let bVector = CIVector(x: -0.02, y: 0.01, z: 1.02, w: 0)
|
|
295
|
+
private static let aVector = CIVector(x: 0, y: 0, z: 0, w: 1)
|
|
296
|
+
private static let biasVector = CIVector(x: 5.0 / 255.0, y: 3.0 / 255.0, z: 0, w: 0)
|
|
297
|
+
|
|
298
|
+
// MARK: - Color Processing
|
|
299
|
+
|
|
300
|
+
/// Apply S-curve tone mapping.
|
|
301
|
+
private static func applyToneCurve(_ image: CIImage) -> CIImage {
|
|
302
|
+
guard let filter = CIFilter(name: "CIToneCurve") else { return image }
|
|
303
|
+
filter.setValue(image, forKey: kCIInputImageKey)
|
|
304
|
+
filter.setValue(toneCurvePoint0, forKey: "inputPoint0")
|
|
305
|
+
filter.setValue(toneCurvePoint1, forKey: "inputPoint1")
|
|
306
|
+
filter.setValue(toneCurvePoint2, forKey: "inputPoint2")
|
|
307
|
+
filter.setValue(toneCurvePoint3, forKey: "inputPoint3")
|
|
308
|
+
filter.setValue(toneCurvePoint4, forKey: "inputPoint4")
|
|
309
|
+
return filter.outputImage ?? image
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/// Apply vibrance — selectively boosts undersaturated colors.
|
|
313
|
+
private static func applyVibrance(_ image: CIImage) -> CIImage {
|
|
314
|
+
guard let filter = CIFilter(name: "CIVibrance") else { return image }
|
|
315
|
+
filter.setValue(image, forKey: kCIInputImageKey)
|
|
316
|
+
filter.setValue(vibranceAmount, forKey: "inputAmount")
|
|
317
|
+
return filter.outputImage ?? image
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/// Apply color correction (warmth/white balance) via CIColorMatrix.
|
|
321
|
+
private static func applyColorCorrection(_ image: CIImage) -> CIImage {
|
|
322
|
+
guard let filter = CIFilter(name: "CIColorMatrix") else { return image }
|
|
323
|
+
filter.setValue(image, forKey: kCIInputImageKey)
|
|
324
|
+
filter.setValue(rVector, forKey: "inputRVector")
|
|
325
|
+
filter.setValue(gVector, forKey: "inputGVector")
|
|
326
|
+
filter.setValue(bVector, forKey: "inputBVector")
|
|
327
|
+
filter.setValue(aVector, forKey: "inputAVector")
|
|
328
|
+
filter.setValue(biasVector, forKey: "inputBiasVector")
|
|
329
|
+
return filter.outputImage ?? image
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// MARK: - Private Helpers
|
|
333
|
+
|
|
334
|
+
private static func parseImuData(_ path: String) -> [ImuSample]? {
|
|
335
|
+
guard let data = FileManager.default.contents(atPath: path),
|
|
336
|
+
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
|
337
|
+
let samples = json["samples"] as? [[Any]]
|
|
338
|
+
else {
|
|
339
|
+
return nil
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
return samples.compactMap { s -> ImuSample? in
|
|
343
|
+
guard s.count >= 7 else { return nil }
|
|
344
|
+
return ImuSample(
|
|
345
|
+
timeMs: (s[0] as? NSNumber)?.doubleValue ?? 0,
|
|
346
|
+
ax: (s[1] as? NSNumber)?.doubleValue ?? 0,
|
|
347
|
+
ay: (s[2] as? NSNumber)?.doubleValue ?? 0,
|
|
348
|
+
az: (s[3] as? NSNumber)?.doubleValue ?? 0,
|
|
349
|
+
gx: (s[4] as? NSNumber)?.doubleValue ?? 0,
|
|
350
|
+
gy: (s[5] as? NSNumber)?.doubleValue ?? 0,
|
|
351
|
+
gz: (s[6] as? NSNumber)?.doubleValue ?? 0
|
|
352
|
+
)
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
private static func smoothEmaMultiPass(_ data: [Double]) -> [Double] {
|
|
357
|
+
guard !data.isEmpty else { return data }
|
|
358
|
+
var result = data
|
|
359
|
+
for _ in 0 ..< SMOOTH_PASSES {
|
|
360
|
+
var smooth = [Double](repeating: 0, count: result.count)
|
|
361
|
+
smooth[0] = result[0]
|
|
362
|
+
// Forward pass
|
|
363
|
+
for i in 1 ..< result.count {
|
|
364
|
+
smooth[i] = SMOOTH_FACTOR * smooth[i - 1] + (1 - SMOOTH_FACTOR) * result[i]
|
|
365
|
+
}
|
|
366
|
+
// Backward pass (zero-phase)
|
|
367
|
+
for i in stride(from: result.count - 2, through: 0, by: -1) {
|
|
368
|
+
smooth[i] = SMOOTH_FACTOR * smooth[i + 1] + (1 - SMOOTH_FACTOR) * smooth[i]
|
|
369
|
+
}
|
|
370
|
+
result = smooth
|
|
371
|
+
}
|
|
372
|
+
return result
|
|
373
|
+
}
|
|
374
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import CoreLocation
|
|
2
|
+
|
|
3
|
+
final class HeadingManager: NSObject, CLLocationManagerDelegate {
|
|
4
|
+
static let shared = HeadingManager()
|
|
5
|
+
|
|
6
|
+
typealias Callback = (Float) -> Void
|
|
7
|
+
|
|
8
|
+
private var locationManager: CLLocationManager?
|
|
9
|
+
private var callback: Callback?
|
|
10
|
+
private var lastEmitted: Float = -1000
|
|
11
|
+
private var started = false
|
|
12
|
+
|
|
13
|
+
private static let minDelta: Float = 1.0
|
|
14
|
+
|
|
15
|
+
func start(callback: @escaping Callback) {
|
|
16
|
+
guard !started else { return }
|
|
17
|
+
started = true
|
|
18
|
+
self.callback = callback
|
|
19
|
+
|
|
20
|
+
// CLLocationManager must be created and used on the main thread (needs a run loop).
|
|
21
|
+
NSLog("[HeadingManager] start called — dispatching CLLocationManager init to main thread")
|
|
22
|
+
DispatchQueue.main.async { [weak self] in
|
|
23
|
+
guard let self, self.started else { return }
|
|
24
|
+
let lm = CLLocationManager()
|
|
25
|
+
lm.delegate = self
|
|
26
|
+
lm.headingFilter = Double(Self.minDelta)
|
|
27
|
+
self.locationManager = lm
|
|
28
|
+
|
|
29
|
+
let status = lm.authorizationStatus
|
|
30
|
+
NSLog("[HeadingManager] authorizationStatus=%d — %@", status.rawValue,
|
|
31
|
+
status == .notDetermined ? "requesting auth" : "starting heading updates")
|
|
32
|
+
if status == .notDetermined {
|
|
33
|
+
lm.requestWhenInUseAuthorization()
|
|
34
|
+
} else {
|
|
35
|
+
lm.startUpdatingHeading()
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
func stop() {
|
|
41
|
+
DispatchQueue.main.async { [weak self] in
|
|
42
|
+
self?.locationManager?.stopUpdatingHeading()
|
|
43
|
+
self?.locationManager = nil
|
|
44
|
+
}
|
|
45
|
+
callback = nil
|
|
46
|
+
lastEmitted = -1000
|
|
47
|
+
started = false
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
|
|
51
|
+
let status = manager.authorizationStatus
|
|
52
|
+
NSLog("[HeadingManager] authorizationChanged status=%d", status.rawValue)
|
|
53
|
+
if status == .authorizedWhenInUse || status == .authorizedAlways {
|
|
54
|
+
NSLog("[HeadingManager] authorized — starting heading updates")
|
|
55
|
+
manager.startUpdatingHeading()
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
func locationManager(_ manager: CLLocationManager, didUpdateHeading newHeading: CLHeading) {
|
|
60
|
+
let raw = newHeading.magneticHeading
|
|
61
|
+
let degrees = Float(raw)
|
|
62
|
+
guard abs(angleDiff(degrees, lastEmitted)) >= Self.minDelta else { return }
|
|
63
|
+
lastEmitted = degrees
|
|
64
|
+
NSLog("[HeadingManager] heading=%.1f°", degrees)
|
|
65
|
+
callback?(degrees)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
private func angleDiff(_ a: Float, _ b: Float) -> Float {
|
|
69
|
+
var d = (a - b).truncatingRemainder(dividingBy: 360)
|
|
70
|
+
if d > 180 { d -= 360 }
|
|
71
|
+
if d < -180 { d += 360 }
|
|
72
|
+
return d
|
|
73
|
+
}
|
|
74
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import CoreLocation
|
|
2
|
+
import Foundation
|
|
3
|
+
import MapboxDirections
|
|
4
|
+
|
|
5
|
+
// Maps the Mapbox Directions maneuver (type + direction) to the string values
|
|
6
|
+
// the JS layer expects — the SAME vocabulary the Android Mapbox migration
|
|
7
|
+
// emits via NavigationManager.kt `mapManeuver()`, which is in turn the same
|
|
8
|
+
// vocabulary the old Google iOS path produced. The miniapp SDK's
|
|
9
|
+
// `ManeuverKind` is frozen, so these strings must not change.
|
|
10
|
+
//
|
|
11
|
+
// Mapbox Directions splits a maneuver into:
|
|
12
|
+
// • ManeuverType — depart / turn / fork / merge / arrive / continue / …
|
|
13
|
+
// • ManeuverDirection — left / right / slightLeft / slightRight / sharpLeft /
|
|
14
|
+
// sharpRight / straight / uTurn
|
|
15
|
+
// We combine them the way Android does (type drives the verb; direction
|
|
16
|
+
// drives left/right + slight/sharp variants).
|
|
17
|
+
//
|
|
18
|
+
// VERIFY-IN-XCODE: enum case spellings (`.turn`, `.slightLeft`, etc.) are from
|
|
19
|
+
// MapboxDirections v3. Confirm against the installed SDK if the compiler
|
|
20
|
+
// flags any case name.
|
|
21
|
+
func maneuverString(type: ManeuverType?, direction: ManeuverDirection?) -> String {
|
|
22
|
+
switch type {
|
|
23
|
+
case .some(.arrive):
|
|
24
|
+
return "ARRIVE"
|
|
25
|
+
case .some(.depart):
|
|
26
|
+
return "DEPART"
|
|
27
|
+
case .some(.reachFork), .some(.merge), .some(.takeOnRamp), .some(.takeOffRamp):
|
|
28
|
+
// Forks / merges / ramps map to the SLIGHT variant of their side.
|
|
29
|
+
switch direction {
|
|
30
|
+
case .some(.left), .some(.slightLeft), .some(.sharpLeft):
|
|
31
|
+
return "SLIGHT_LEFT"
|
|
32
|
+
case .some(.right), .some(.slightRight), .some(.sharpRight):
|
|
33
|
+
return "SLIGHT_RIGHT"
|
|
34
|
+
default:
|
|
35
|
+
return "STRAIGHT"
|
|
36
|
+
}
|
|
37
|
+
default:
|
|
38
|
+
// turn / continue / endOfRoad / roundabout / rotary / unknown — direction
|
|
39
|
+
// drives the left/right + slight/sharp/u-turn family.
|
|
40
|
+
return directionString(direction)
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/// Map a ManeuverDirection to the left/right family used by the JS layer.
|
|
45
|
+
private func directionString(_ direction: ManeuverDirection?) -> String {
|
|
46
|
+
switch direction {
|
|
47
|
+
case .some(.left): return "TURN_LEFT"
|
|
48
|
+
case .some(.right): return "TURN_RIGHT"
|
|
49
|
+
case .some(.slightLeft): return "SLIGHT_LEFT"
|
|
50
|
+
case .some(.slightRight): return "SLIGHT_RIGHT"
|
|
51
|
+
case .some(.sharpLeft): return "SHARP_LEFT"
|
|
52
|
+
case .some(.sharpRight): return "SHARP_RIGHT"
|
|
53
|
+
case .some(.uTurn): return "U_TURN"
|
|
54
|
+
default: return "STRAIGHT"
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/// Convert a list of route coordinates to the `[{lat,lng}]` array the JS bridge
|
|
59
|
+
/// expects (matching Android's RoutePoint shape).
|
|
60
|
+
func coordinatesToPoints(_ coords: [CLLocationCoordinate2D]) -> [[String: Double]] {
|
|
61
|
+
coords.map { ["lat": $0.latitude, "lng": $0.longitude] }
|
|
62
|
+
}
|