@rejourneyco/react-native 1.0.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/android/build.gradle.kts +135 -0
- package/android/consumer-rules.pro +10 -0
- package/android/proguard-rules.pro +1 -0
- package/android/src/main/AndroidManifest.xml +15 -0
- package/android/src/main/java/com/rejourney/RejourneyModuleImpl.kt +2981 -0
- package/android/src/main/java/com/rejourney/capture/ANRHandler.kt +206 -0
- package/android/src/main/java/com/rejourney/capture/ActivityTracker.kt +98 -0
- package/android/src/main/java/com/rejourney/capture/CaptureEngine.kt +1553 -0
- package/android/src/main/java/com/rejourney/capture/CaptureHeuristics.kt +375 -0
- package/android/src/main/java/com/rejourney/capture/CrashHandler.kt +153 -0
- package/android/src/main/java/com/rejourney/capture/MotionEvent.kt +215 -0
- package/android/src/main/java/com/rejourney/capture/SegmentUploader.kt +512 -0
- package/android/src/main/java/com/rejourney/capture/VideoEncoder.kt +773 -0
- package/android/src/main/java/com/rejourney/capture/ViewHierarchyScanner.kt +633 -0
- package/android/src/main/java/com/rejourney/capture/ViewSerializer.kt +286 -0
- package/android/src/main/java/com/rejourney/core/Constants.kt +117 -0
- package/android/src/main/java/com/rejourney/core/Logger.kt +93 -0
- package/android/src/main/java/com/rejourney/core/Types.kt +124 -0
- package/android/src/main/java/com/rejourney/lifecycle/SessionLifecycleService.kt +162 -0
- package/android/src/main/java/com/rejourney/network/DeviceAuthManager.kt +747 -0
- package/android/src/main/java/com/rejourney/network/HttpClientProvider.kt +16 -0
- package/android/src/main/java/com/rejourney/network/NetworkMonitor.kt +272 -0
- package/android/src/main/java/com/rejourney/network/UploadManager.kt +1363 -0
- package/android/src/main/java/com/rejourney/network/UploadWorker.kt +492 -0
- package/android/src/main/java/com/rejourney/privacy/PrivacyMask.kt +645 -0
- package/android/src/main/java/com/rejourney/touch/GestureClassifier.kt +233 -0
- package/android/src/main/java/com/rejourney/touch/KeyboardTracker.kt +158 -0
- package/android/src/main/java/com/rejourney/touch/TextInputTracker.kt +181 -0
- package/android/src/main/java/com/rejourney/touch/TouchInterceptor.kt +591 -0
- package/android/src/main/java/com/rejourney/utils/EventBuffer.kt +284 -0
- package/android/src/main/java/com/rejourney/utils/OEMDetector.kt +154 -0
- package/android/src/main/java/com/rejourney/utils/PerfTiming.kt +235 -0
- package/android/src/main/java/com/rejourney/utils/Telemetry.kt +297 -0
- package/android/src/main/java/com/rejourney/utils/WindowUtils.kt +84 -0
- package/android/src/newarch/java/com/rejourney/RejourneyModule.kt +187 -0
- package/android/src/newarch/java/com/rejourney/RejourneyPackage.kt +40 -0
- package/android/src/oldarch/java/com/rejourney/RejourneyModule.kt +218 -0
- package/android/src/oldarch/java/com/rejourney/RejourneyPackage.kt +23 -0
- package/ios/Capture/RJANRHandler.h +42 -0
- package/ios/Capture/RJANRHandler.m +328 -0
- package/ios/Capture/RJCaptureEngine.h +275 -0
- package/ios/Capture/RJCaptureEngine.m +2062 -0
- package/ios/Capture/RJCaptureHeuristics.h +80 -0
- package/ios/Capture/RJCaptureHeuristics.m +903 -0
- package/ios/Capture/RJCrashHandler.h +46 -0
- package/ios/Capture/RJCrashHandler.m +313 -0
- package/ios/Capture/RJMotionEvent.h +183 -0
- package/ios/Capture/RJMotionEvent.m +183 -0
- package/ios/Capture/RJPerformanceManager.h +100 -0
- package/ios/Capture/RJPerformanceManager.m +373 -0
- package/ios/Capture/RJPixelBufferDownscaler.h +42 -0
- package/ios/Capture/RJPixelBufferDownscaler.m +85 -0
- package/ios/Capture/RJSegmentUploader.h +146 -0
- package/ios/Capture/RJSegmentUploader.m +778 -0
- package/ios/Capture/RJVideoEncoder.h +247 -0
- package/ios/Capture/RJVideoEncoder.m +1036 -0
- package/ios/Capture/RJViewControllerTracker.h +73 -0
- package/ios/Capture/RJViewControllerTracker.m +508 -0
- package/ios/Capture/RJViewHierarchyScanner.h +215 -0
- package/ios/Capture/RJViewHierarchyScanner.m +1464 -0
- package/ios/Capture/RJViewSerializer.h +119 -0
- package/ios/Capture/RJViewSerializer.m +498 -0
- package/ios/Core/RJConstants.h +124 -0
- package/ios/Core/RJConstants.m +88 -0
- package/ios/Core/RJLifecycleManager.h +85 -0
- package/ios/Core/RJLifecycleManager.m +308 -0
- package/ios/Core/RJLogger.h +61 -0
- package/ios/Core/RJLogger.m +211 -0
- package/ios/Core/RJTypes.h +176 -0
- package/ios/Core/RJTypes.m +66 -0
- package/ios/Core/Rejourney.h +64 -0
- package/ios/Core/Rejourney.mm +2495 -0
- package/ios/Network/RJDeviceAuthManager.h +94 -0
- package/ios/Network/RJDeviceAuthManager.m +967 -0
- package/ios/Network/RJNetworkMonitor.h +68 -0
- package/ios/Network/RJNetworkMonitor.m +267 -0
- package/ios/Network/RJRetryManager.h +73 -0
- package/ios/Network/RJRetryManager.m +325 -0
- package/ios/Network/RJUploadManager.h +267 -0
- package/ios/Network/RJUploadManager.m +2296 -0
- package/ios/Privacy/RJPrivacyMask.h +163 -0
- package/ios/Privacy/RJPrivacyMask.m +922 -0
- package/ios/Rejourney.h +63 -0
- package/ios/Touch/RJGestureClassifier.h +130 -0
- package/ios/Touch/RJGestureClassifier.m +333 -0
- package/ios/Touch/RJTouchInterceptor.h +169 -0
- package/ios/Touch/RJTouchInterceptor.m +772 -0
- package/ios/Utils/RJEventBuffer.h +112 -0
- package/ios/Utils/RJEventBuffer.m +358 -0
- package/ios/Utils/RJGzipUtils.h +33 -0
- package/ios/Utils/RJGzipUtils.m +89 -0
- package/ios/Utils/RJKeychainManager.h +48 -0
- package/ios/Utils/RJKeychainManager.m +111 -0
- package/ios/Utils/RJPerfTiming.h +209 -0
- package/ios/Utils/RJPerfTiming.m +264 -0
- package/ios/Utils/RJTelemetry.h +92 -0
- package/ios/Utils/RJTelemetry.m +320 -0
- package/ios/Utils/RJWindowUtils.h +66 -0
- package/ios/Utils/RJWindowUtils.m +133 -0
- package/lib/commonjs/NativeRejourney.js +40 -0
- package/lib/commonjs/components/Mask.js +79 -0
- package/lib/commonjs/index.js +1381 -0
- package/lib/commonjs/sdk/autoTracking.js +1259 -0
- package/lib/commonjs/sdk/constants.js +151 -0
- package/lib/commonjs/sdk/errorTracking.js +199 -0
- package/lib/commonjs/sdk/index.js +50 -0
- package/lib/commonjs/sdk/metricsTracking.js +204 -0
- package/lib/commonjs/sdk/navigation.js +151 -0
- package/lib/commonjs/sdk/networkInterceptor.js +412 -0
- package/lib/commonjs/sdk/utils.js +363 -0
- package/lib/commonjs/types/expo-router.d.js +2 -0
- package/lib/commonjs/types/index.js +2 -0
- package/lib/module/NativeRejourney.js +38 -0
- package/lib/module/components/Mask.js +72 -0
- package/lib/module/index.js +1284 -0
- package/lib/module/sdk/autoTracking.js +1233 -0
- package/lib/module/sdk/constants.js +145 -0
- package/lib/module/sdk/errorTracking.js +189 -0
- package/lib/module/sdk/index.js +12 -0
- package/lib/module/sdk/metricsTracking.js +187 -0
- package/lib/module/sdk/navigation.js +143 -0
- package/lib/module/sdk/networkInterceptor.js +401 -0
- package/lib/module/sdk/utils.js +342 -0
- package/lib/module/types/expo-router.d.js +2 -0
- package/lib/module/types/index.js +2 -0
- package/lib/typescript/NativeRejourney.d.ts +147 -0
- package/lib/typescript/components/Mask.d.ts +39 -0
- package/lib/typescript/index.d.ts +117 -0
- package/lib/typescript/sdk/autoTracking.d.ts +204 -0
- package/lib/typescript/sdk/constants.d.ts +120 -0
- package/lib/typescript/sdk/errorTracking.d.ts +32 -0
- package/lib/typescript/sdk/index.d.ts +9 -0
- package/lib/typescript/sdk/metricsTracking.d.ts +58 -0
- package/lib/typescript/sdk/navigation.d.ts +33 -0
- package/lib/typescript/sdk/networkInterceptor.d.ts +47 -0
- package/lib/typescript/sdk/utils.d.ts +148 -0
- package/lib/typescript/types/index.d.ts +624 -0
- package/package.json +102 -0
- package/rejourney.podspec +21 -0
- package/src/NativeRejourney.ts +165 -0
- package/src/components/Mask.tsx +80 -0
- package/src/index.ts +1459 -0
- package/src/sdk/autoTracking.ts +1373 -0
- package/src/sdk/constants.ts +134 -0
- package/src/sdk/errorTracking.ts +231 -0
- package/src/sdk/index.ts +11 -0
- package/src/sdk/metricsTracking.ts +232 -0
- package/src/sdk/navigation.ts +157 -0
- package/src/sdk/networkInterceptor.ts +440 -0
- package/src/sdk/utils.ts +369 -0
- package/src/types/expo-router.d.ts +7 -0
- package/src/types/index.ts +739 -0
|
@@ -0,0 +1,773 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* H.264 video segment encoder using MediaCodec/MediaMuxer.
|
|
3
|
+
* Ported from iOS RJVideoEncoder.
|
|
4
|
+
*
|
|
5
|
+
* Provides continuous 2 FPS video capture with predictable CPU usage.
|
|
6
|
+
* Each segment is a self-contained .mp4 file that can be uploaded independently.
|
|
7
|
+
*
|
|
8
|
+
* ## Features
|
|
9
|
+
* - H.264 Baseline profile for maximum compatibility
|
|
10
|
+
* - Configurable bitrate (default 1.2 Mbps for quality)
|
|
11
|
+
* - Automatic segment rotation after N frames
|
|
12
|
+
* - Thread-safe frame appending
|
|
13
|
+
* - Emergency flush for crash handling
|
|
14
|
+
*
|
|
15
|
+
* Licensed under the Apache License, Version 2.0
|
|
16
|
+
* Copyright (c) 2026 Rejourney
|
|
17
|
+
*/
|
|
18
|
+
package com.rejourney.capture
|
|
19
|
+
|
|
20
|
+
import android.graphics.Bitmap
|
|
21
|
+
import android.media.MediaCodec
|
|
22
|
+
import android.media.MediaCodecInfo
|
|
23
|
+
import android.media.MediaCodecList
|
|
24
|
+
import android.media.MediaFormat
|
|
25
|
+
import android.media.MediaMuxer
|
|
26
|
+
import android.os.Handler
|
|
27
|
+
import android.os.HandlerThread
|
|
28
|
+
import android.view.Surface
|
|
29
|
+
import com.rejourney.core.Logger
|
|
30
|
+
import java.io.File
|
|
31
|
+
import java.nio.ByteBuffer
|
|
32
|
+
import java.util.concurrent.atomic.AtomicBoolean
|
|
33
|
+
import java.util.concurrent.atomic.AtomicInteger
|
|
34
|
+
import java.util.concurrent.atomic.AtomicLong
|
|
35
|
+
import java.util.concurrent.locks.ReentrantLock
|
|
36
|
+
import kotlin.concurrent.withLock
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Delegate interface for receiving completed video segment notifications.
|
|
40
|
+
*/
|
|
41
|
+
interface VideoEncoderDelegate {
|
|
42
|
+
/**
|
|
43
|
+
* Called when a video segment has been finalized and is ready for upload.
|
|
44
|
+
*
|
|
45
|
+
* @param segmentFile Local file path of the completed .mp4 segment.
|
|
46
|
+
* @param startTime Segment start time in epoch milliseconds.
|
|
47
|
+
* @param endTime Segment end time in epoch milliseconds.
|
|
48
|
+
* @param frameCount Number of frames encoded in this segment.
|
|
49
|
+
*/
|
|
50
|
+
fun onSegmentFinished(segmentFile: File, startTime: Long, endTime: Long, frameCount: Int)
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Called when encoding fails.
|
|
54
|
+
*
|
|
55
|
+
* @param error The error that occurred during encoding.
|
|
56
|
+
*/
|
|
57
|
+
fun onEncodingError(error: Exception)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
class VideoEncoder(private val segmentDir: File) {
|
|
61
|
+
|
|
62
|
+
// Configuration - matching iOS RJVideoEncoder defaults for quality and efficiency
|
|
63
|
+
/** Target video bitrate in bits per second. Default: 1.5 Mbps - matches iOS */
|
|
64
|
+
var targetBitrate: Int = 1_500_000
|
|
65
|
+
|
|
66
|
+
/** Number of frames per segment before auto-rotation. Default: 60 (4s at 15 FPS) */
|
|
67
|
+
var framesPerSegment: Int = 60
|
|
68
|
+
|
|
69
|
+
/** Target frames per second for video timing. Default: 15 */
|
|
70
|
+
var fps: Int = 15
|
|
71
|
+
|
|
72
|
+
/** Scale factor for capturing (0.0-1.0). Default: 0.35 (35% scale) - matches iOS */
|
|
73
|
+
var captureScale: Float = 0.35f
|
|
74
|
+
|
|
75
|
+
/** Maximum dimension in pixels (longest edge). Default: 1920 - matches iOS */
|
|
76
|
+
var maxDimension: Int = 1920
|
|
77
|
+
|
|
78
|
+
/** Keyframe interval in seconds. Default: 10 - matches iOS (fewer keyframes = smaller files) */
|
|
79
|
+
var keyframeInterval: Int = 10
|
|
80
|
+
|
|
81
|
+
/** Display density used to normalize pixel sizes (dp parity with iOS points). */
|
|
82
|
+
var displayDensity: Float = 1f
|
|
83
|
+
|
|
84
|
+
/** Delegate for receiving segment completion notifications */
|
|
85
|
+
var delegate: VideoEncoderDelegate? = null
|
|
86
|
+
|
|
87
|
+
// State
|
|
88
|
+
private var encoder: MediaCodec? = null
|
|
89
|
+
private var muxer: MediaMuxer? = null
|
|
90
|
+
private var inputSurface: Surface? = null
|
|
91
|
+
private var trackIndex: Int = -1
|
|
92
|
+
private var muxerStarted = AtomicBoolean(false)
|
|
93
|
+
|
|
94
|
+
private var currentSegmentFile: File? = null
|
|
95
|
+
private var frameCount = AtomicInteger(0)
|
|
96
|
+
private var segmentStartTime = AtomicLong(0)
|
|
97
|
+
private var segmentFirstFrameTimestamp = AtomicLong(0)
|
|
98
|
+
private var lastFrameTimestamp = AtomicLong(0)
|
|
99
|
+
private var currentFrameWidth = 0
|
|
100
|
+
private var currentFrameHeight = 0
|
|
101
|
+
private val presentationTimesUs = ArrayDeque<Long>()
|
|
102
|
+
private var lastPresentationTimeUs = 0L
|
|
103
|
+
|
|
104
|
+
// CRITICAL FIX: Track original unscaled dimensions for segment rotation
|
|
105
|
+
// Without this, finishSegmentAndContinue() would pass already-scaled dimensions
|
|
106
|
+
// to startSegment(), causing double-scaling and degrading video quality
|
|
107
|
+
private var originalRequestedWidth = 0
|
|
108
|
+
private var originalRequestedHeight = 0
|
|
109
|
+
|
|
110
|
+
private var sessionId: String? = null
|
|
111
|
+
|
|
112
|
+
// Encoding thread
|
|
113
|
+
private var encodingThread: HandlerThread? = null
|
|
114
|
+
private var encodingHandler: Handler? = null
|
|
115
|
+
|
|
116
|
+
// Buffer info for draining encoder
|
|
117
|
+
private val bufferInfo = MediaCodec.BufferInfo()
|
|
118
|
+
|
|
119
|
+
// Lock for thread-safe encoder access
|
|
120
|
+
// Prevents concurrent calls to drainEncoder from appendFrame and finishSegment
|
|
121
|
+
private val encoderLock = ReentrantLock()
|
|
122
|
+
|
|
123
|
+
// isRecording: Allow frames once encoder and surface are ready
|
|
124
|
+
// NOTE: The muxer starts asynchronously when the first frame is encoded and
|
|
125
|
+
// drainEncoder receives INFO_OUTPUT_FORMAT_CHANGED. We cannot require muxerStarted
|
|
126
|
+
// here because that would create a chicken-and-egg problem where no frames can be
|
|
127
|
+
// appended to trigger the muxer to start.
|
|
128
|
+
val isRecording: Boolean
|
|
129
|
+
get() = encoder != null && inputSurface != null
|
|
130
|
+
|
|
131
|
+
val currentFrameCount: Int
|
|
132
|
+
get() = frameCount.get()
|
|
133
|
+
|
|
134
|
+
// Prewarm state
|
|
135
|
+
private var isPrewarmed = false
|
|
136
|
+
|
|
137
|
+
init {
|
|
138
|
+
segmentDir.mkdirs()
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Pre-warm the encoder to eliminate first-frame encoding spike.
|
|
143
|
+
* Call this during SDK initialization before first capture.
|
|
144
|
+
* Matches iOS prewarmPixelBufferPool behavior.
|
|
145
|
+
*/
|
|
146
|
+
fun prewarm() {
|
|
147
|
+
if (isPrewarmed) return
|
|
148
|
+
|
|
149
|
+
try {
|
|
150
|
+
// Pre-query available H.264 encoders to cache codec info
|
|
151
|
+
val codecInfo = findEncoder(MediaFormat.MIMETYPE_VIDEO_AVC)
|
|
152
|
+
if (codecInfo != null) {
|
|
153
|
+
Logger.debug("[VideoEncoder] Prewarm: found encoder ${codecInfo.name}")
|
|
154
|
+
isPrewarmed = true
|
|
155
|
+
} else {
|
|
156
|
+
Logger.warning("[VideoEncoder] Prewarm: no H.264 encoder found")
|
|
157
|
+
}
|
|
158
|
+
} catch (e: Exception) {
|
|
159
|
+
Logger.warning("[VideoEncoder] Prewarm failed: ${e.message}")
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Sets the session ID for the current recording session.
|
|
165
|
+
*/
|
|
166
|
+
fun setSessionId(id: String) {
|
|
167
|
+
sessionId = id
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Starts a new video segment with the specified frame size.
|
|
172
|
+
* If a segment is already in progress, it will be finished first.
|
|
173
|
+
*
|
|
174
|
+
* @param width Frame width in pixels (will be scaled by captureScale)
|
|
175
|
+
* @param height Frame height in pixels (will be scaled by captureScale)
|
|
176
|
+
* @return true if segment started successfully, false otherwise
|
|
177
|
+
*/
|
|
178
|
+
fun startSegment(width: Int, height: Int): Boolean {
|
|
179
|
+
Logger.debug("[VideoEncoder] startSegment: ${width}x${height}")
|
|
180
|
+
|
|
181
|
+
// If already recording, finish current segment first
|
|
182
|
+
if (isRecording) {
|
|
183
|
+
Logger.debug("[VideoEncoder] Already recording, finishing previous segment")
|
|
184
|
+
finishSegment()
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// CRITICAL FIX: Store original unscaled dimensions BEFORE scaling
|
|
188
|
+
// Used by finishSegmentInternal() during segment rotation to prevent double-scaling
|
|
189
|
+
originalRequestedWidth = width
|
|
190
|
+
originalRequestedHeight = height
|
|
191
|
+
|
|
192
|
+
// Apply scale factor using native pixels (matching iOS)
|
|
193
|
+
var scaledWidth = (width * captureScale).toInt()
|
|
194
|
+
var scaledHeight = (height * captureScale).toInt()
|
|
195
|
+
|
|
196
|
+
// Apply max dimension cap (like iOS)
|
|
197
|
+
val maxDim = maxOf(scaledWidth, scaledHeight)
|
|
198
|
+
if (maxDim > maxDimension) {
|
|
199
|
+
val scale = maxDimension.toFloat() / maxDim.toFloat()
|
|
200
|
+
scaledWidth = (scaledWidth * scale).toInt()
|
|
201
|
+
scaledHeight = (scaledHeight * scale).toInt()
|
|
202
|
+
Logger.debug("[VideoEncoder] Applied max dimension cap: ${scaledWidth}x${scaledHeight}")
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Ensure dimensions are even (required for H.264)
|
|
206
|
+
scaledWidth = (scaledWidth / 2) * 2
|
|
207
|
+
scaledHeight = (scaledHeight / 2) * 2
|
|
208
|
+
|
|
209
|
+
// Minimum size check
|
|
210
|
+
if (scaledWidth < 100 || scaledHeight < 100) {
|
|
211
|
+
Logger.warning("[VideoEncoder] Frame size too small, using minimum 100x100")
|
|
212
|
+
scaledWidth = 100
|
|
213
|
+
scaledHeight = 100
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
currentFrameWidth = scaledWidth
|
|
217
|
+
currentFrameHeight = scaledHeight
|
|
218
|
+
|
|
219
|
+
// Reset counters
|
|
220
|
+
frameCount.set(0)
|
|
221
|
+
lastFrameTimestamp.set(0)
|
|
222
|
+
segmentFirstFrameTimestamp.set(0)
|
|
223
|
+
presentationTimesUs.clear()
|
|
224
|
+
lastPresentationTimeUs = 0L
|
|
225
|
+
segmentStartTime.set(System.currentTimeMillis())
|
|
226
|
+
|
|
227
|
+
// Generate unique filename
|
|
228
|
+
val sessionPrefix = sessionId ?: "unknown"
|
|
229
|
+
val filename = "seg_${sessionPrefix}_${segmentStartTime.get()}.mp4"
|
|
230
|
+
currentSegmentFile = File(segmentDir, filename)
|
|
231
|
+
|
|
232
|
+
// Delete existing file if any
|
|
233
|
+
currentSegmentFile?.delete()
|
|
234
|
+
|
|
235
|
+
Logger.debug("[VideoEncoder] Creating segment: $filename (${scaledWidth}x${scaledHeight})")
|
|
236
|
+
|
|
237
|
+
return try {
|
|
238
|
+
// Start encoding thread
|
|
239
|
+
startEncodingThread()
|
|
240
|
+
|
|
241
|
+
// Configure encoder
|
|
242
|
+
configureEncoder(scaledWidth, scaledHeight)
|
|
243
|
+
|
|
244
|
+
true
|
|
245
|
+
} catch (e: Exception) {
|
|
246
|
+
Logger.error("[VideoEncoder] Failed to start segment", e)
|
|
247
|
+
cleanup()
|
|
248
|
+
delegate?.onEncodingError(e)
|
|
249
|
+
false
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Appends a frame to the current video segment.
|
|
255
|
+
* If the segment reaches framesPerSegment, it auto-rotates to a new segment.
|
|
256
|
+
*
|
|
257
|
+
* @param bitmap The screenshot bitmap to encode
|
|
258
|
+
* @param timestamp The capture timestamp in epoch milliseconds
|
|
259
|
+
* @return true if frame was appended successfully, false otherwise
|
|
260
|
+
*/
|
|
261
|
+
fun appendFrame(bitmap: Bitmap, timestamp: Long): Boolean {
|
|
262
|
+
if (frameCount.get() % 10 == 0) {
|
|
263
|
+
Logger.debug("[VideoEncoder] appendFrame: count=${frameCount.get()}, isRecording=$isRecording")
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
if (!isRecording) {
|
|
267
|
+
Logger.warning("[VideoEncoder] Cannot append frame, not recording")
|
|
268
|
+
return false
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
val surface = inputSurface ?: run {
|
|
272
|
+
Logger.warning("[VideoEncoder] Cannot append frame, no input surface")
|
|
273
|
+
return false
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
try {
|
|
277
|
+
// Scale bitmap if needed
|
|
278
|
+
val scaledBitmap = if (bitmap.width != currentFrameWidth || bitmap.height != currentFrameHeight) {
|
|
279
|
+
Bitmap.createScaledBitmap(bitmap, currentFrameWidth, currentFrameHeight, true)
|
|
280
|
+
} else {
|
|
281
|
+
bitmap
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Track first frame timestamp for presentation time calculation
|
|
285
|
+
if (frameCount.get() == 0) {
|
|
286
|
+
segmentFirstFrameTimestamp.set(timestamp)
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
// Draw bitmap to encoder input surface
|
|
291
|
+
val canvas = surface.lockHardwareCanvas()
|
|
292
|
+
try {
|
|
293
|
+
canvas.drawBitmap(scaledBitmap, 0f, 0f, null)
|
|
294
|
+
} finally {
|
|
295
|
+
surface.unlockCanvasAndPost(canvas)
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// Recycle scaled bitmap if we created a new one
|
|
299
|
+
if (scaledBitmap != bitmap) {
|
|
300
|
+
scaledBitmap.recycle()
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// Drain encoder output (thread-safe)
|
|
304
|
+
encoderLock.withLock {
|
|
305
|
+
val presentationTimeUs = computePresentationTimeUs(timestamp)
|
|
306
|
+
presentationTimesUs.add(presentationTimeUs)
|
|
307
|
+
drainEncoder(false)
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
frameCount.incrementAndGet()
|
|
311
|
+
lastFrameTimestamp.set(timestamp)
|
|
312
|
+
|
|
313
|
+
if (frameCount.get() % 10 == 0) {
|
|
314
|
+
Logger.debug("[VideoEncoder] Frame appended: ${frameCount.get()}/$framesPerSegment")
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// Auto-rotate segment if we've reached the limit
|
|
318
|
+
if (frameCount.get() >= framesPerSegment) {
|
|
319
|
+
Logger.info("[VideoEncoder] Segment full (${frameCount.get()} frames), rotating")
|
|
320
|
+
finishSegmentAndContinue()
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
return true
|
|
324
|
+
} catch (e: Exception) {
|
|
325
|
+
Logger.error("[VideoEncoder] Failed to append frame", e)
|
|
326
|
+
return false
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Finishes the current segment and notifies the delegate.
|
|
332
|
+
*/
|
|
333
|
+
fun finishSegment() {
|
|
334
|
+
finishSegmentInternal(shouldContinue = false)
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Finishes the current segment and starts a new one (rotation).
|
|
339
|
+
*/
|
|
340
|
+
private fun finishSegmentAndContinue() {
|
|
341
|
+
finishSegmentInternal(shouldContinue = true)
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
private fun finishSegmentInternal(shouldContinue: Boolean) {
|
|
345
|
+
Logger.debug("[VideoEncoder] finishSegment: continue=$shouldContinue, frames=${frameCount.get()}")
|
|
346
|
+
|
|
347
|
+
val enc = encoder ?: run {
|
|
348
|
+
Logger.debug("[VideoEncoder] No encoder, nothing to finish")
|
|
349
|
+
return
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
val count = frameCount.get()
|
|
353
|
+
val segmentFile = currentSegmentFile
|
|
354
|
+
val startTime = if (segmentFirstFrameTimestamp.get() > 0) {
|
|
355
|
+
segmentFirstFrameTimestamp.get()
|
|
356
|
+
} else {
|
|
357
|
+
segmentStartTime.get()
|
|
358
|
+
}
|
|
359
|
+
val endTime = if (lastFrameTimestamp.get() > 0) {
|
|
360
|
+
lastFrameTimestamp.get()
|
|
361
|
+
} else {
|
|
362
|
+
System.currentTimeMillis()
|
|
363
|
+
}
|
|
364
|
+
val width = currentFrameWidth
|
|
365
|
+
val height = currentFrameHeight
|
|
366
|
+
|
|
367
|
+
// Skip if no frames were written
|
|
368
|
+
if (count == 0) {
|
|
369
|
+
Logger.debug("[VideoEncoder] No frames in segment, canceling")
|
|
370
|
+
cancelSegment()
|
|
371
|
+
return
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
try {
|
|
375
|
+
// Thread-safe: Lock to prevent race with appendFrame's drainEncoder
|
|
376
|
+
encoderLock.withLock {
|
|
377
|
+
// Signal end of stream
|
|
378
|
+
enc.signalEndOfInputStream()
|
|
379
|
+
|
|
380
|
+
// Drain remaining output
|
|
381
|
+
drainEncoder(true)
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// Stop and release muxer
|
|
385
|
+
if (muxerStarted.get()) {
|
|
386
|
+
muxer?.stop()
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// Get file size for logging
|
|
390
|
+
val fileSize = segmentFile?.length() ?: 0
|
|
391
|
+
Logger.info("[VideoEncoder] Segment complete - $count frames, ${fileSize / 1024.0} KB, ${(endTime - startTime) / 1000.0}s")
|
|
392
|
+
|
|
393
|
+
// Notify delegate
|
|
394
|
+
segmentFile?.let {
|
|
395
|
+
delegate?.onSegmentFinished(it, startTime, endTime, count)
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
} catch (e: Exception) {
|
|
399
|
+
Logger.error("[VideoEncoder] Failed to finish segment", e)
|
|
400
|
+
delegate?.onEncodingError(e)
|
|
401
|
+
} finally {
|
|
402
|
+
cleanup()
|
|
403
|
+
|
|
404
|
+
// Start new segment if requested
|
|
405
|
+
if (shouldContinue && sessionId != null) {
|
|
406
|
+
Logger.debug("[VideoEncoder] Starting new segment after rotation")
|
|
407
|
+
// CRITICAL FIX: Use original unscaled dimensions, NOT width/height
|
|
408
|
+
// which are the already-scaled currentFrameWidth/currentFrameHeight.
|
|
409
|
+
// Using scaled dimensions would cause double-scaling:
|
|
410
|
+
// 1080x2424 -> 356x800 (first segment) -> 142x320 (second segment)
|
|
411
|
+
startSegment(originalRequestedWidth, originalRequestedHeight)
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* Cancels the current segment without saving.
|
|
418
|
+
*/
|
|
419
|
+
fun cancelSegment() {
|
|
420
|
+
cleanup()
|
|
421
|
+
currentSegmentFile?.delete()
|
|
422
|
+
currentSegmentFile = null
|
|
423
|
+
frameCount.set(0)
|
|
424
|
+
lastFrameTimestamp.set(0)
|
|
425
|
+
segmentFirstFrameTimestamp.set(0)
|
|
426
|
+
presentationTimesUs.clear()
|
|
427
|
+
lastPresentationTimeUs = 0L
|
|
428
|
+
Logger.debug("[VideoEncoder] Segment canceled")
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* Cleans up encoder resources and pending segments.
|
|
433
|
+
*/
|
|
434
|
+
fun cleanup() {
|
|
435
|
+
try {
|
|
436
|
+
inputSurface?.release()
|
|
437
|
+
inputSurface = null
|
|
438
|
+
} catch (e: Exception) {
|
|
439
|
+
Logger.debug("[VideoEncoder] Error releasing input surface: ${e.message}")
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
try {
|
|
443
|
+
encoder?.stop()
|
|
444
|
+
} catch (e: Exception) {
|
|
445
|
+
Logger.debug("[VideoEncoder] Error stopping encoder: ${e.message}")
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
try {
|
|
449
|
+
encoder?.release()
|
|
450
|
+
} catch (e: Exception) {
|
|
451
|
+
Logger.debug("[VideoEncoder] Error releasing encoder: ${e.message}")
|
|
452
|
+
}
|
|
453
|
+
encoder = null
|
|
454
|
+
|
|
455
|
+
try {
|
|
456
|
+
if (muxerStarted.get()) {
|
|
457
|
+
muxer?.release()
|
|
458
|
+
}
|
|
459
|
+
} catch (e: Exception) {
|
|
460
|
+
Logger.debug("[VideoEncoder] Error releasing muxer: ${e.message}")
|
|
461
|
+
}
|
|
462
|
+
muxer = null
|
|
463
|
+
muxerStarted.set(false)
|
|
464
|
+
trackIndex = -1
|
|
465
|
+
presentationTimesUs.clear()
|
|
466
|
+
lastPresentationTimeUs = 0L
|
|
467
|
+
|
|
468
|
+
stopEncodingThread()
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/**
|
|
472
|
+
* Emergency synchronous flush for crash handling.
|
|
473
|
+
* Attempts to finalize the current segment so it can be recovered.
|
|
474
|
+
*
|
|
475
|
+
* @return true if segment was successfully finalized, false otherwise
|
|
476
|
+
*/
|
|
477
|
+
fun emergencyFlushSync(): Boolean {
|
|
478
|
+
if (!isRecording || frameCount.get() == 0) {
|
|
479
|
+
return false
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
Logger.warning("[VideoEncoder] Emergency flush - attempting to save ${frameCount.get()} frames")
|
|
483
|
+
|
|
484
|
+
return try {
|
|
485
|
+
// Save segment metadata to disk for recovery
|
|
486
|
+
saveCrashSegmentMetadata()
|
|
487
|
+
|
|
488
|
+
// Thread-safe: Lock to prevent race with appendFrame
|
|
489
|
+
encoderLock.withLock {
|
|
490
|
+
// Try to finalize
|
|
491
|
+
encoder?.signalEndOfInputStream()
|
|
492
|
+
drainEncoder(true)
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
if (muxerStarted.get()) {
|
|
496
|
+
muxer?.stop()
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
Logger.info("[VideoEncoder] Emergency flush succeeded")
|
|
500
|
+
true
|
|
501
|
+
} catch (e: Exception) {
|
|
502
|
+
Logger.error("[VideoEncoder] Emergency flush failed", e)
|
|
503
|
+
false
|
|
504
|
+
} finally {
|
|
505
|
+
cleanup()
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
private fun saveCrashSegmentMetadata() {
|
|
510
|
+
try {
|
|
511
|
+
val metaFile = File(segmentDir, "pending_crash_segment.json")
|
|
512
|
+
val meta = """
|
|
513
|
+
{
|
|
514
|
+
"sessionId": "${sessionId ?: ""}",
|
|
515
|
+
"segmentFile": "${currentSegmentFile?.absolutePath ?: ""}",
|
|
516
|
+
"startTime": ${segmentFirstFrameTimestamp.get()},
|
|
517
|
+
"endTime": ${lastFrameTimestamp.get()},
|
|
518
|
+
"frameCount": ${frameCount.get()}
|
|
519
|
+
}
|
|
520
|
+
""".trimIndent()
|
|
521
|
+
metaFile.writeText(meta)
|
|
522
|
+
} catch (e: Exception) {
|
|
523
|
+
Logger.error("[VideoEncoder] Failed to save crash segment metadata", e)
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
private fun configureEncoder(width: Int, height: Int) {
|
|
528
|
+
// Find H.264 encoder
|
|
529
|
+
val codecInfo = findEncoder(MediaFormat.MIMETYPE_VIDEO_AVC)
|
|
530
|
+
?: throw IllegalStateException("No H.264 encoder available")
|
|
531
|
+
|
|
532
|
+
// Configure format - matching iOS RJVideoEncoder settings
|
|
533
|
+
val format = MediaFormat.createVideoFormat(MediaFormat.MIMETYPE_VIDEO_AVC, width, height).apply {
|
|
534
|
+
setInteger(MediaFormat.KEY_COLOR_FORMAT, MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface)
|
|
535
|
+
setInteger(MediaFormat.KEY_BIT_RATE, targetBitrate)
|
|
536
|
+
setInteger(MediaFormat.KEY_FRAME_RATE, fps)
|
|
537
|
+
setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, keyframeInterval) // 10s keyframes like iOS for smaller files
|
|
538
|
+
|
|
539
|
+
// Baseline profile to reduce encoder CPU cost (matches iOS Baseline+CAVLC change)
|
|
540
|
+
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.LOLLIPOP) {
|
|
541
|
+
setInteger(MediaFormat.KEY_PROFILE, MediaCodecInfo.CodecProfileLevel.AVCProfileBaseline)
|
|
542
|
+
setInteger(MediaFormat.KEY_LEVEL, MediaCodecInfo.CodecProfileLevel.AVCLevel31)
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
// Create encoder
|
|
547
|
+
encoder = MediaCodec.createByCodecName(codecInfo.name).apply {
|
|
548
|
+
configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE)
|
|
549
|
+
inputSurface = createInputSurface()
|
|
550
|
+
start()
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
// Create muxer
|
|
554
|
+
muxer = MediaMuxer(currentSegmentFile!!.absolutePath, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4)
|
|
555
|
+
|
|
556
|
+
Logger.debug("[VideoEncoder] Encoder configured: ${codecInfo.name} ${width}x${height} @ ${targetBitrate}bps")
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
private fun findEncoder(mimeType: String): MediaCodecInfo? {
|
|
560
|
+
val codecList = MediaCodecList(MediaCodecList.REGULAR_CODECS)
|
|
561
|
+
for (codecInfo in codecList.codecInfos) {
|
|
562
|
+
if (!codecInfo.isEncoder) continue
|
|
563
|
+
for (type in codecInfo.supportedTypes) {
|
|
564
|
+
if (type.equals(mimeType, ignoreCase = true)) {
|
|
565
|
+
return codecInfo
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
return null
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
private fun computePresentationTimeUs(timestamp: Long): Long {
|
|
573
|
+
val firstTimestamp = segmentFirstFrameTimestamp.get().takeIf { it > 0 } ?: timestamp
|
|
574
|
+
var presentationTimeUs = ((timestamp - firstTimestamp).coerceAtLeast(0L)) * 1000L
|
|
575
|
+
if (presentationTimeUs <= lastPresentationTimeUs) {
|
|
576
|
+
presentationTimeUs = lastPresentationTimeUs + 1
|
|
577
|
+
}
|
|
578
|
+
lastPresentationTimeUs = presentationTimeUs
|
|
579
|
+
return presentationTimeUs
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
private fun drainEncoder(endOfStream: Boolean) {
|
|
583
|
+
val enc = encoder ?: return
|
|
584
|
+
val mux = muxer ?: return
|
|
585
|
+
|
|
586
|
+
while (true) {
|
|
587
|
+
val outputBufferIndex = enc.dequeueOutputBuffer(bufferInfo, 10000)
|
|
588
|
+
|
|
589
|
+
when {
|
|
590
|
+
outputBufferIndex == MediaCodec.INFO_TRY_AGAIN_LATER -> {
|
|
591
|
+
if (!endOfStream) break
|
|
592
|
+
// Continue draining if end of stream
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
outputBufferIndex == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED -> {
|
|
596
|
+
if (muxerStarted.get()) {
|
|
597
|
+
Logger.warning("[VideoEncoder] Format changed after muxer started")
|
|
598
|
+
}
|
|
599
|
+
val newFormat = enc.outputFormat
|
|
600
|
+
trackIndex = mux.addTrack(newFormat)
|
|
601
|
+
mux.start()
|
|
602
|
+
muxerStarted.set(true)
|
|
603
|
+
Logger.debug("[VideoEncoder] Muxer started with track $trackIndex")
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
outputBufferIndex >= 0 -> {
|
|
607
|
+
val encodedData = enc.getOutputBuffer(outputBufferIndex)
|
|
608
|
+
?: throw RuntimeException("Encoder output buffer $outputBufferIndex was null")
|
|
609
|
+
|
|
610
|
+
if (bufferInfo.flags and MediaCodec.BUFFER_FLAG_CODEC_CONFIG != 0) {
|
|
611
|
+
// Codec config data - skip
|
|
612
|
+
bufferInfo.size = 0
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
if (bufferInfo.size > 0 && muxerStarted.get()) {
|
|
616
|
+
encodedData.position(bufferInfo.offset)
|
|
617
|
+
encodedData.limit(bufferInfo.offset + bufferInfo.size)
|
|
618
|
+
|
|
619
|
+
val presentationTimeUs = if (presentationTimesUs.isNotEmpty()) {
|
|
620
|
+
presentationTimesUs.removeFirst()
|
|
621
|
+
} else {
|
|
622
|
+
(frameCount.get() * 1_000_000L) / fps
|
|
623
|
+
}
|
|
624
|
+
bufferInfo.presentationTimeUs = presentationTimeUs
|
|
625
|
+
|
|
626
|
+
mux.writeSampleData(trackIndex, encodedData, bufferInfo)
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
enc.releaseOutputBuffer(outputBufferIndex, false)
|
|
630
|
+
|
|
631
|
+
if (bufferInfo.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0) {
|
|
632
|
+
Logger.debug("[VideoEncoder] End of stream reached")
|
|
633
|
+
break
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
else -> {
|
|
638
|
+
Logger.warning("[VideoEncoder] Unexpected output buffer result: $outputBufferIndex")
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
private fun startEncodingThread() {
|
|
645
|
+
if (encodingThread != null) return
|
|
646
|
+
|
|
647
|
+
encodingThread = HandlerThread("VideoEncoderThread").apply {
|
|
648
|
+
start()
|
|
649
|
+
}
|
|
650
|
+
encodingHandler = Handler(encodingThread!!.looper)
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
private fun stopEncodingThread() {
|
|
654
|
+
encodingThread?.quitSafely()
|
|
655
|
+
try {
|
|
656
|
+
encodingThread?.join(100)
|
|
657
|
+
} catch (e: InterruptedException) {
|
|
658
|
+
Logger.debug("[VideoEncoder] Interrupted waiting for encoding thread")
|
|
659
|
+
}
|
|
660
|
+
encodingThread = null
|
|
661
|
+
encodingHandler = null
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
companion object {
|
|
665
|
+
// Class-level prewarm state (matching iOS +prewarmEncoderAsync)
|
|
666
|
+
@Volatile
|
|
667
|
+
private var staticPrewarmed = false
|
|
668
|
+
|
|
669
|
+
/**
|
|
670
|
+
* Class-level encoder pre-warm to eliminate first-frame encoding spike.
|
|
671
|
+
* Call this during CaptureEngine initialization before first session.
|
|
672
|
+
*
|
|
673
|
+
* This matches iOS +[RJVideoEncoder prewarmEncoderAsync] behavior:
|
|
674
|
+
* - Runs on background thread to avoid blocking main thread
|
|
675
|
+
* - Uses dispatch_once equivalent (synchronized + flag) to run only once
|
|
676
|
+
* - Front-loads ~50-100ms of MediaCodec initialization cost
|
|
677
|
+
*/
|
|
678
|
+
fun prewarmEncoderAsync() {
|
|
679
|
+
if (staticPrewarmed) return
|
|
680
|
+
|
|
681
|
+
Thread {
|
|
682
|
+
synchronized(this) {
|
|
683
|
+
if (staticPrewarmed) return@Thread
|
|
684
|
+
staticPrewarmed = true
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
try {
|
|
688
|
+
val startTime = System.nanoTime()
|
|
689
|
+
|
|
690
|
+
// Query available H.264 encoders to cache codec info in system
|
|
691
|
+
val codecList = MediaCodecList(MediaCodecList.REGULAR_CODECS)
|
|
692
|
+
val codecInfos = codecList.codecInfos
|
|
693
|
+
|
|
694
|
+
var encoderName: String? = null
|
|
695
|
+
for (info in codecInfos) {
|
|
696
|
+
if (!info.isEncoder) continue
|
|
697
|
+
try {
|
|
698
|
+
val types = info.supportedTypes
|
|
699
|
+
if (types.any { it.equals(MediaFormat.MIMETYPE_VIDEO_AVC, ignoreCase = true) }) {
|
|
700
|
+
encoderName = info.name
|
|
701
|
+
// Touch capabilities to trigger JIT/caching
|
|
702
|
+
val caps = info.getCapabilitiesForType(MediaFormat.MIMETYPE_VIDEO_AVC)
|
|
703
|
+
caps.videoCapabilities
|
|
704
|
+
caps.encoderCapabilities
|
|
705
|
+
break
|
|
706
|
+
}
|
|
707
|
+
} catch (_: Exception) {
|
|
708
|
+
continue
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
val elapsed = (System.nanoTime() - startTime) / 1_000_000.0
|
|
713
|
+
Logger.info("[VideoEncoder] H.264 class prewarm completed in ${elapsed}ms (encoder: $encoderName)")
|
|
714
|
+
|
|
715
|
+
} catch (e: Exception) {
|
|
716
|
+
Logger.warning("[VideoEncoder] Class prewarm failed: ${e.message}")
|
|
717
|
+
}
|
|
718
|
+
}.start()
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
/**
|
|
722
|
+
* Checks if there is a pending video segment from a crash.
|
|
723
|
+
*
|
|
724
|
+
* @param segmentDir The segment directory to check
|
|
725
|
+
* @return Segment metadata if a pending segment exists, null otherwise
|
|
726
|
+
*/
|
|
727
|
+
fun getPendingCrashSegmentMetadata(segmentDir: File): Map<String, Any>? {
|
|
728
|
+
val metaFile = File(segmentDir, "pending_crash_segment.json")
|
|
729
|
+
if (!metaFile.exists()) return null
|
|
730
|
+
|
|
731
|
+
return try {
|
|
732
|
+
val json = metaFile.readText()
|
|
733
|
+
val result = mutableMapOf<String, Any>()
|
|
734
|
+
// Simple JSON parsing (avoiding JSONObject for minimal dependencies)
|
|
735
|
+
json.lines().forEach { line ->
|
|
736
|
+
val trimmed = line.trim().removeSuffix(",")
|
|
737
|
+
when {
|
|
738
|
+
trimmed.contains("\"sessionId\"") -> {
|
|
739
|
+
result["sessionId"] = trimmed.substringAfter(":").trim().removeSurrounding("\"")
|
|
740
|
+
}
|
|
741
|
+
trimmed.contains("\"segmentFile\"") -> {
|
|
742
|
+
result["segmentFile"] = trimmed.substringAfter(":").trim().removeSurrounding("\"")
|
|
743
|
+
}
|
|
744
|
+
trimmed.contains("\"startTime\"") -> {
|
|
745
|
+
result["startTime"] = trimmed.substringAfter(":").trim().toLongOrNull() ?: 0L
|
|
746
|
+
}
|
|
747
|
+
trimmed.contains("\"endTime\"") -> {
|
|
748
|
+
result["endTime"] = trimmed.substringAfter(":").trim().toLongOrNull() ?: 0L
|
|
749
|
+
}
|
|
750
|
+
trimmed.contains("\"frameCount\"") -> {
|
|
751
|
+
result["frameCount"] = trimmed.substringAfter(":").trim().toIntOrNull() ?: 0
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
result.ifEmpty { null }
|
|
756
|
+
} catch (e: Exception) {
|
|
757
|
+
Logger.error("[VideoEncoder] Failed to read crash segment metadata", e)
|
|
758
|
+
null
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
/**
|
|
763
|
+
* Clears the pending crash segment metadata after recovery.
|
|
764
|
+
*/
|
|
765
|
+
fun clearPendingCrashSegmentMetadata(segmentDir: File) {
|
|
766
|
+
try {
|
|
767
|
+
File(segmentDir, "pending_crash_segment.json").delete()
|
|
768
|
+
} catch (e: Exception) {
|
|
769
|
+
Logger.debug("[VideoEncoder] Failed to clear crash segment metadata: ${e.message}")
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
}
|