react-native-compressor 1.18.2 → 1.19.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.
@@ -0,0 +1,397 @@
1
+ package com.reactnativecompressor.Video.VideoCompressor.utils
2
+
3
+ import android.content.Context
4
+ import android.net.Uri
5
+ import android.os.ParcelFileDescriptor
6
+ import android.util.Log
7
+ import java.io.File
8
+ import java.io.FileInputStream
9
+ import java.nio.ByteBuffer
10
+ import java.nio.ByteOrder
11
+ import java.nio.channels.FileChannel
12
+ import java.nio.charset.StandardCharsets
13
+ import java.util.Locale
14
+
15
+ /**
16
+ * Raw MP4 walker that recovers an ISO 6709 GPS string when
17
+ * MediaMetadataRetriever.METADATA_KEY_LOCATION fails to return one.
18
+ *
19
+ * Why this exists: device vendors disagree on where GPS lives.
20
+ * - Most phones write Apple "©xyz" under moov/udta or moov/trak/udta.
21
+ * - Some ISO-compliant captures use the standard "loci" box.
22
+ * - Newer iOS / Android captures use the iTunes-style
23
+ * moov/meta/keys + moov/meta/ilst pair with the key
24
+ * "com.apple.quicktime.location.ISO6709".
25
+ *
26
+ * Android's retriever only reads movie-level "©xyz" and silently returns
27
+ * null for everything else. The walker descends through every container
28
+ * atom and tries each known encoding in priority order.
29
+ */
30
+ object LocationExtractor {
31
+
32
+ // Share the "Compressor" log tag so the atom dump is visible alongside
33
+ // the existing pipeline diagnostics without needing an extra logcat filter.
34
+ private const val TAG = "Compressor"
35
+
36
+ private val CONTAINER_TYPES = setOf("moov", "trak", "mdia", "minf", "udta", "meta", "ilst")
37
+
38
+ fun extract(context: Context, uri: Uri): String? {
39
+ Log.i(TAG, "LocationExtractor.extract uri=$uri")
40
+ return try {
41
+ openChannel(context, uri)?.use { channel ->
42
+ Log.i(TAG, "LocationExtractor: file size=${channel.size()}")
43
+ val state = WalkState()
44
+ walk(channel, 0L, channel.size(), state, depth = 0)
45
+ val viaBox = chooseBest(state)
46
+ // Log only presence, never the coordinate strings — these are the
47
+ // user's exact GPS values and must not land in production logcat.
48
+ Log.i(
49
+ TAG,
50
+ "LocationExtractor box scan: hasXyz=${!state.xyz.isNullOrEmpty()} hasItunesLocation=${!state.itunesLocation.isNullOrEmpty()} hasLoci=${!state.loci.isNullOrEmpty()} hasChosenLocation=${!viaBox.isNullOrEmpty()}"
51
+ )
52
+ // Samsung phones (Galaxy S10 / Android 12 verified) write GPS into
53
+ // an SEF (Samsung Extended Format) trailer that sits after mdat,
54
+ // outside the standard MP4 box hierarchy. The trailer contains
55
+ // an ISO 6709 string in plain ASCII. Scan the file tail and let
56
+ // the strict regex extract it.
57
+ viaBox ?: scanTrailerForIso6709(channel)
58
+ }
59
+ } catch (e: Exception) {
60
+ Log.w(TAG, "LocationExtractor extract failed", e)
61
+ null
62
+ }
63
+ }
64
+
65
+ /**
66
+ * Open a FileChannel for either a content:// URI (via ContentResolver) or
67
+ * a raw filesystem path. JS layer hands the compressor URIs in three
68
+ * shapes — content://, file://, and bare /storage/... paths — and the
69
+ * latter cannot be opened through ContentResolver.
70
+ */
71
+ private fun openChannel(context: Context, uri: Uri): FileChannel? {
72
+ val scheme = uri.scheme
73
+ if (scheme == null || scheme == "file") {
74
+ val path = uri.path ?: uri.toString()
75
+ val file = File(path)
76
+ if (!file.exists()) {
77
+ Log.w(TAG, "LocationExtractor: file does not exist $path")
78
+ return null
79
+ }
80
+ return FileInputStream(file).channel
81
+ }
82
+ val pfd = context.contentResolver.openFileDescriptor(uri, "r")
83
+ if (pfd == null) {
84
+ Log.w(TAG, "LocationExtractor: openFileDescriptor returned null for $uri")
85
+ return null
86
+ }
87
+ // AutoCloseInputStream closes the ParcelFileDescriptor when the stream
88
+ // is closed. A bare FileInputStream over pfd.fileDescriptor would leak
89
+ // the pfd until finalizer runs.
90
+ return ParcelFileDescriptor.AutoCloseInputStream(pfd).channel
91
+ }
92
+
93
+ // Strict ISO 6709 pattern: signed lat, signed lon, optional signed alt,
94
+ // mandatory trailing slash. Tight enough that random bytes inside mdat
95
+ // virtually never match, lenient enough to accept the small precision
96
+ // variations vendors use.
97
+ private val ISO6709_REGEX = Regex(
98
+ "[+-]\\d{1,3}\\.\\d{2,7}[+-]\\d{1,3}\\.\\d{2,7}([+-]\\d{1,5}(\\.\\d+)?)?/"
99
+ )
100
+
101
+ private fun scanTrailerForIso6709(channel: FileChannel): String? {
102
+ val size = channel.size()
103
+ // 1 MiB tail covers every SEF trailer observed so far. Capped so very
104
+ // small clips do not read past start of file.
105
+ val tailSize = minOf(size, 1L shl 20).toInt()
106
+ if (tailSize <= 0) return null
107
+ val start = size - tailSize
108
+ val buf = ByteBuffer.allocate(tailSize)
109
+ channel.position(start)
110
+ if (channel.read(buf) <= 0) return null
111
+ buf.flip()
112
+ val bytes = ByteArray(buf.remaining())
113
+ buf.get(bytes)
114
+ val text = String(bytes, StandardCharsets.ISO_8859_1)
115
+ val match = ISO6709_REGEX.find(text)
116
+ Log.i(TAG, "LocationExtractor SEF trailer scan matched=${match != null}")
117
+ return match?.value
118
+ }
119
+
120
+ private class WalkState {
121
+ var xyz: String? = null
122
+ var loci: String? = null
123
+ var itunesLocation: String? = null
124
+ // iTunes-style meta state.
125
+ val itunesKeys: ArrayList<String> = ArrayList()
126
+ var insideMeta: Boolean = false
127
+ }
128
+
129
+ private fun chooseBest(s: WalkState): String? {
130
+ return s.xyz?.takeIf { it.isNotEmpty() }
131
+ ?: s.itunesLocation?.takeIf { it.isNotEmpty() }
132
+ ?: s.loci?.takeIf { it.isNotEmpty() }
133
+ }
134
+
135
+ private fun walk(
136
+ channel: FileChannel,
137
+ start: Long,
138
+ end: Long,
139
+ state: WalkState,
140
+ depth: Int,
141
+ ) {
142
+ var pos = start
143
+ val header = ByteBuffer.allocate(16).order(ByteOrder.BIG_ENDIAN)
144
+ while (pos + 8 <= end) {
145
+ header.clear()
146
+ header.limit(8)
147
+ channel.position(pos)
148
+ if (channel.read(header) < 8) break
149
+ header.flip()
150
+ val rawSize = header.int.toLong() and 0xFFFFFFFFL
151
+ val typeBytes = ByteArray(4)
152
+ header.get(typeBytes)
153
+ val type = fourCC(typeBytes)
154
+
155
+ var headerSize = 8L
156
+ var boxSize = rawSize
157
+ if (rawSize == 1L) {
158
+ val ext = ByteBuffer.allocate(8).order(ByteOrder.BIG_ENDIAN)
159
+ channel.position(pos + 8)
160
+ if (channel.read(ext) < 8) break
161
+ ext.flip()
162
+ boxSize = ext.long
163
+ headerSize = 16L
164
+ } else if (rawSize == 0L) {
165
+ boxSize = end - pos
166
+ }
167
+
168
+ if (boxSize < headerSize || pos + boxSize > end) break
169
+ val childEnd = pos + boxSize
170
+ val childStart = pos + headerSize
171
+
172
+ if (depth < 5) {
173
+ // Use Log.i so the atom tree appears in default logcat output and
174
+ // can be pasted back when GPS extraction misses a vendor-specific
175
+ // box layout. Depth-bounded to avoid spamming on large mdat chunks.
176
+ Log.i(TAG, "LocationExtractor atom $type @ $pos size=$boxSize depth=$depth")
177
+ }
178
+
179
+ when {
180
+ // Apple Quicktime "©xyz" - 0xA9 'x' 'y' 'z'.
181
+ typeBytes[0] == 0xA9.toByte() &&
182
+ typeBytes[1] == 'x'.code.toByte() &&
183
+ typeBytes[2] == 'y'.code.toByte() &&
184
+ typeBytes[3] == 'z'.code.toByte() -> {
185
+ val parsed = readXyz(channel, childStart, childEnd)
186
+ if (!parsed.isNullOrEmpty() && state.xyz == null) {
187
+ state.xyz = parsed
188
+ Log.i(TAG, "found ©xyz")
189
+ }
190
+ }
191
+
192
+ // ISO 14496-12 "loci" location box.
193
+ type == "loci" -> {
194
+ val parsed = readLoci(channel, childStart, childEnd)
195
+ if (!parsed.isNullOrEmpty() && state.loci == null) {
196
+ state.loci = parsed
197
+ Log.i(TAG, "found loci")
198
+ }
199
+ }
200
+
201
+ // iTunes-style metadata under moov/meta.
202
+ type == "keys" && state.insideMeta -> {
203
+ parseItunesKeys(channel, childStart, childEnd, state)
204
+ }
205
+ type == "ilst" && state.insideMeta -> {
206
+ parseItunesIlst(channel, childStart, childEnd, state)
207
+ }
208
+ }
209
+
210
+ if (type in CONTAINER_TYPES) {
211
+ // "meta" has a 4-byte version+flags prefix before its children.
212
+ val innerStart = if (type == "meta") childStart + 4 else childStart
213
+ val priorMeta = state.insideMeta
214
+ if (type == "meta") state.insideMeta = true
215
+ walk(channel, innerStart, childEnd, state, depth + 1)
216
+ state.insideMeta = priorMeta
217
+ }
218
+
219
+ pos = childEnd
220
+ }
221
+ }
222
+
223
+ private fun fourCC(b: ByteArray): String {
224
+ val sb = StringBuilder(4)
225
+ for (byte in b) {
226
+ val c = byte.toInt() and 0xFF
227
+ sb.append(if (c in 0x20..0x7E) c.toChar() else '?')
228
+ }
229
+ return sb.toString()
230
+ }
231
+
232
+ private fun readBoxContent(channel: FileChannel, start: Long, end: Long): ByteBuffer? {
233
+ val len = (end - start).toInt()
234
+ if (len <= 0) return null
235
+ val buf = ByteBuffer.allocate(len).order(ByteOrder.BIG_ENDIAN)
236
+ channel.position(start)
237
+ if (channel.read(buf) < len) return null
238
+ buf.flip()
239
+ return buf
240
+ }
241
+
242
+ /**
243
+ * Apple "©xyz" content:
244
+ * uint16 length
245
+ * uint16 language code (packed)
246
+ * bytes ISO 6709 string
247
+ */
248
+ private fun readXyz(channel: FileChannel, start: Long, end: Long): String? {
249
+ val buf = readBoxContent(channel, start, end) ?: return null
250
+ if (buf.remaining() < 4) return null
251
+ val len = buf.short.toInt() and 0xFFFF
252
+ buf.short
253
+ val take = minOf(len, buf.remaining())
254
+ if (take <= 0) return null
255
+ val bytes = ByteArray(take)
256
+ buf.get(bytes)
257
+ return String(bytes, StandardCharsets.UTF_8).trim().ifEmpty { null }
258
+ }
259
+
260
+ /**
261
+ * ISO 14496-12 "loci" content:
262
+ * uint8 version
263
+ * uint24 flags
264
+ * uint16 language
265
+ * utf8z name
266
+ * uint8 role
267
+ * uint32 longitude (16.16 fixed)
268
+ * uint32 latitude (16.16 fixed)
269
+ * uint32 altitude (16.16 fixed)
270
+ * ...
271
+ */
272
+ private fun readLoci(channel: FileChannel, start: Long, end: Long): String? {
273
+ val buf = readBoxContent(channel, start, end) ?: return null
274
+ if (buf.remaining() < 6) return null
275
+ buf.int // version + flags
276
+ buf.short // language
277
+ // Skip null-terminated name.
278
+ while (buf.hasRemaining() && buf.get() != 0.toByte()) { /* skip */ }
279
+ if (buf.remaining() < 1 + 12) return null
280
+ buf.get() // role
281
+ val longitude = fixedPoint1616(buf.int)
282
+ val latitude = fixedPoint1616(buf.int)
283
+ val altitude = fixedPoint1616(buf.int)
284
+ return formatIso6709(latitude, longitude, altitude)
285
+ }
286
+
287
+ private fun fixedPoint1616(raw: Int): Double {
288
+ return raw.toDouble() / 65536.0
289
+ }
290
+
291
+ private fun formatIso6709(lat: Double, lon: Double, alt: Double): String {
292
+ val sb = StringBuilder()
293
+ sb.append(if (lat >= 0) "+" else "")
294
+ sb.append(String.format(Locale.US, "%.4f", lat))
295
+ sb.append(if (lon >= 0) "+" else "")
296
+ sb.append(String.format(Locale.US, "%.4f", lon))
297
+ if (alt != 0.0) {
298
+ sb.append(if (alt >= 0) "+" else "")
299
+ sb.append(String.format(Locale.US, "%.3f", alt))
300
+ }
301
+ sb.append('/')
302
+ return sb.toString()
303
+ }
304
+
305
+ /**
306
+ * Apple iTunes-style "keys" box content:
307
+ * uint32 version+flags
308
+ * uint32 entry_count
309
+ * for each:
310
+ * uint32 key_size (includes header)
311
+ * uint32 key_namespace ('mdta')
312
+ * bytes key_value (utf-8)
313
+ */
314
+ private fun parseItunesKeys(channel: FileChannel, start: Long, end: Long, state: WalkState) {
315
+ val buf = readBoxContent(channel, start, end) ?: return
316
+ state.itunesKeys.clear()
317
+ if (buf.remaining() < 8) return
318
+ buf.int // version + flags
319
+ val count = buf.int
320
+ for (i in 0 until count) {
321
+ if (buf.remaining() < 8) break
322
+ val entrySize = buf.int
323
+ buf.int // namespace
324
+ val keyLen = entrySize - 8
325
+ if (keyLen <= 0 || keyLen > buf.remaining()) break
326
+ val keyBytes = ByteArray(keyLen)
327
+ buf.get(keyBytes)
328
+ state.itunesKeys.add(String(keyBytes, StandardCharsets.UTF_8))
329
+ }
330
+ Log.i(TAG, "LocationExtractor itunes keys: ${state.itunesKeys}")
331
+ }
332
+
333
+ /**
334
+ * Apple iTunes-style "ilst" box. Each child is an indexed item whose
335
+ * type is a uint32 index pointing back into the "keys" table. Inside
336
+ * each item is a "data" sub-box with the actual payload.
337
+ */
338
+ private fun parseItunesIlst(channel: FileChannel, start: Long, end: Long, state: WalkState) {
339
+ var pos = start
340
+ val header = ByteBuffer.allocate(8).order(ByteOrder.BIG_ENDIAN)
341
+ while (pos + 8 <= end) {
342
+ header.clear()
343
+ channel.position(pos)
344
+ if (channel.read(header) < 8) break
345
+ header.flip()
346
+ val itemSize = header.int.toLong() and 0xFFFFFFFFL
347
+ val indexBytes = ByteArray(4)
348
+ header.get(indexBytes)
349
+ val index = ByteBuffer.wrap(indexBytes).order(ByteOrder.BIG_ENDIAN).int
350
+ if (itemSize < 8 || pos + itemSize > end) break
351
+ val itemEnd = pos + itemSize
352
+ val key = state.itunesKeys.getOrNull(index - 1)
353
+ if (key == "com.apple.quicktime.location.ISO6709") {
354
+ val payload = findItunesData(channel, pos + 8, itemEnd)
355
+ if (!payload.isNullOrEmpty() && state.itunesLocation == null) {
356
+ state.itunesLocation = payload
357
+ Log.i(TAG, "found itunes location")
358
+ }
359
+ }
360
+ pos = itemEnd
361
+ }
362
+ }
363
+
364
+ private fun findItunesData(channel: FileChannel, start: Long, end: Long): String? {
365
+ var pos = start
366
+ val header = ByteBuffer.allocate(8).order(ByteOrder.BIG_ENDIAN)
367
+ while (pos + 8 <= end) {
368
+ header.clear()
369
+ channel.position(pos)
370
+ if (channel.read(header) < 8) break
371
+ header.flip()
372
+ val size = header.int.toLong() and 0xFFFFFFFFL
373
+ val typeBytes = ByteArray(4)
374
+ header.get(typeBytes)
375
+ val type = fourCC(typeBytes)
376
+ if (size < 8 || pos + size > end) break
377
+ if (type == "data") {
378
+ // data box: uint32 type indicator, uint32 locale, then payload.
379
+ val payloadStart = pos + 8 + 8
380
+ val payloadEnd = pos + size
381
+ if (payloadEnd > payloadStart) {
382
+ val len = (payloadEnd - payloadStart).toInt()
383
+ val buf = ByteBuffer.allocate(len)
384
+ channel.position(payloadStart)
385
+ if (channel.read(buf) >= len) {
386
+ buf.flip()
387
+ val bytes = ByteArray(len)
388
+ buf.get(bytes)
389
+ return String(bytes, StandardCharsets.UTF_8).trim().ifEmpty { null }
390
+ }
391
+ }
392
+ }
393
+ pos += size
394
+ }
395
+ return null
396
+ }
397
+ }
@@ -0,0 +1,47 @@
1
+ package com.reactnativecompressor.Video.VideoCompressor.video
2
+
3
+ import org.mp4parser.support.AbstractBox
4
+ import java.nio.ByteBuffer
5
+ import java.nio.charset.StandardCharsets
6
+
7
+ /**
8
+ * Apple Quicktime "©xyz" box that stores an ISO 6709 location string
9
+ * (e.g. "+37.4220-122.0840/" or "+37.4220-122.0840+009.000/").
10
+ *
11
+ * Android's MediaMetadataRetriever.METADATA_KEY_LOCATION reads this exact
12
+ * box; writing it preserves GPS metadata across the compression rewrite.
13
+ *
14
+ * Layout of the box content:
15
+ * uint16 BE text byte length
16
+ * uint16 BE language packed code (0x15c7 = "und")
17
+ * bytes ISO 6709 string (no NUL terminator)
18
+ */
19
+ class LocationBox : AbstractBox(TYPE) {
20
+
21
+ var location: String = ""
22
+
23
+ override fun getContentSize(): Long {
24
+ val bytes = location.toByteArray(StandardCharsets.UTF_8)
25
+ return (2 + 2 + bytes.size).toLong()
26
+ }
27
+
28
+ override fun _parseDetails(content: ByteBuffer) {
29
+ val len = content.short.toInt() and 0xFFFF
30
+ content.short
31
+ val bytes = ByteArray(len)
32
+ content.get(bytes)
33
+ location = String(bytes, StandardCharsets.UTF_8)
34
+ }
35
+
36
+ override fun getContent(byteBuffer: ByteBuffer) {
37
+ val bytes = location.toByteArray(StandardCharsets.UTF_8)
38
+ byteBuffer.putShort(bytes.size.toShort())
39
+ byteBuffer.putShort(LANG_UND)
40
+ byteBuffer.put(bytes)
41
+ }
42
+
43
+ companion object {
44
+ const val TYPE = "©xyz"
45
+ private const val LANG_UND: Short = 0x15c7
46
+ }
47
+ }
@@ -30,13 +30,20 @@ class MP4Builder {
30
30
  fos = FileOutputStream(mp4Movie.getCacheFile())
31
31
  fc = fos.channel
32
32
 
33
- val fileTypeBox: FileTypeBox = createFileTypeBox()
34
- fileTypeBox.getBox(fc)
35
- dataOffset += fileTypeBox.size
36
- wroteSinceLastMdat = dataOffset
37
-
38
- mdat = Mdat()
39
- sizeBuffer = ByteBuffer.allocateDirect(4)
33
+ // Streams are open now; if header writing throws, the caller never gets
34
+ // a reference to close, so release them here before rethrowing.
35
+ try {
36
+ val fileTypeBox: FileTypeBox = createFileTypeBox()
37
+ fileTypeBox.getBox(fc)
38
+ dataOffset += fileTypeBox.size
39
+ wroteSinceLastMdat = dataOffset
40
+
41
+ mdat = Mdat()
42
+ sizeBuffer = ByteBuffer.allocateDirect(4)
43
+ } catch (e: Exception) {
44
+ close()
45
+ throw e
46
+ }
40
47
 
41
48
  return this
42
49
  }
@@ -131,6 +138,19 @@ class MP4Builder {
131
138
  fos.close()
132
139
  }
133
140
 
141
+ // Close the underlying file streams without finalizing the movie. Used on
142
+ // failure paths where finishMovie() never runs, so the FileOutputStream and
143
+ // its FileChannel opened in createMovie() don't leak. Safe to call when
144
+ // createMovie() failed early (streams not yet initialized) and idempotent.
145
+ fun close() {
146
+ if (::fc.isInitialized) {
147
+ runCatching { fc.close() }
148
+ }
149
+ if (::fos.isInitialized) {
150
+ runCatching { fos.close() }
151
+ }
152
+ }
153
+
134
154
  private fun createFileTypeBox(): FileTypeBox {
135
155
  // completed list can be found at https://www.ftyps.com/
136
156
  val minorBrands = listOf(
@@ -190,6 +210,18 @@ class MP4Builder {
190
210
  movieBox.addBox(createTrackBox(track, movie))
191
211
  }
192
212
 
213
+ // Preserve source GPS metadata. MediaMetadataRetriever and most
214
+ // gallery apps read the Apple "©xyz" box inside moov/udta, so any
215
+ // ISO 6709 string passed in is written there verbatim.
216
+ val location = movie.getLocation()
217
+ if (!location.isNullOrEmpty()) {
218
+ val udta = UserDataBox()
219
+ val xyz = LocationBox()
220
+ xyz.location = location
221
+ udta.addBox(xyz)
222
+ movieBox.addBox(udta)
223
+ }
224
+
193
225
  return movieBox
194
226
  }
195
227
 
@@ -11,6 +11,7 @@ class Mp4Movie {
11
11
  private var matrix = Matrix.ROTATE_0
12
12
  private val tracks = ArrayList<Track>()
13
13
  private var cacheFile: File? = null
14
+ private var location: String? = null
14
15
 
15
16
  fun getMatrix(): Matrix? = matrix
16
17
 
@@ -18,6 +19,12 @@ class Mp4Movie {
18
19
  cacheFile = file
19
20
  }
20
21
 
22
+ fun setLocation(value: String?) {
23
+ location = value
24
+ }
25
+
26
+ fun getLocation(): String? = location
27
+
21
28
  fun setRotation(angle: Int) {
22
29
  when (angle) {
23
30
  0 -> {
@@ -2,6 +2,8 @@ package com.reactnativecompressor.Video.VideoCompressor.video
2
2
 
3
3
  import android.graphics.SurfaceTexture
4
4
  import android.graphics.SurfaceTexture.OnFrameAvailableListener
5
+ import android.os.Handler
6
+ import android.os.HandlerThread
5
7
  import android.view.Surface
6
8
 
7
9
  class OutputSurface : OnFrameAvailableListener {
@@ -12,6 +14,15 @@ class OutputSurface : OnFrameAvailableListener {
12
14
  private var mFrameAvailable = false
13
15
  private var mTextureRender: TextureRenderer? = null
14
16
 
17
+ // Dedicated thread for SurfaceTexture's onFrameAvailable callback.
18
+ // Without this, Android delivers the callback on the main UI thread
19
+ // (because the compression coroutine has no Looper), so awaitNewImage()
20
+ // stalls whenever the main thread is busy with UI / JS bridge work.
21
+ // Routing the callback to its own thread removes that contention and
22
+ // is the single biggest throughput win for the decoder→encoder pipeline.
23
+ private val mCallbackThread = HandlerThread("CompressorSurfaceTexCb").apply { start() }
24
+ private val mCallbackHandler = Handler(mCallbackThread.looper)
25
+
15
26
  /**
16
27
  * Creates an OutputSurface using the current EGL context. This Surface will be
17
28
  * passed to MediaCodec.configure().
@@ -35,7 +46,7 @@ class OutputSurface : OnFrameAvailableListener {
35
46
  // causes the native finalizer to run.
36
47
  mSurfaceTexture = SurfaceTexture(it.getTextureId())
37
48
  mSurfaceTexture?.let { surfaceTexture ->
38
- surfaceTexture.setOnFrameAvailableListener(this)
49
+ surfaceTexture.setOnFrameAvailableListener(this, mCallbackHandler)
39
50
  mSurface = Surface(mSurfaceTexture)
40
51
  }
41
52
  }
@@ -43,6 +54,13 @@ class OutputSurface : OnFrameAvailableListener {
43
54
 
44
55
  /**
45
56
  * Discards all resources held by this class, notably the EGL context.
57
+ *
58
+ * quitSafely() returns immediately; the HandlerThread's native pthread
59
+ * may still be terminating when callers proceed to tear down MediaCodec.
60
+ * If the ART sampling profiler walks threads during that window it can
61
+ * dereference a stale pthread_t and SIGABRT. join(500) blocks until the
62
+ * thread is fully exited (pthread_join) so the pthread_t is no longer
63
+ * tracked. Bounded at 500ms to avoid hanging on a pathological looper.
46
64
  */
47
65
  fun release() {
48
66
  mSurface?.release()
@@ -50,6 +68,13 @@ class OutputSurface : OnFrameAvailableListener {
50
68
  mTextureRender = null
51
69
  mSurface = null
52
70
  mSurfaceTexture = null
71
+
72
+ mCallbackThread.quitSafely()
73
+ try {
74
+ mCallbackThread.join(500)
75
+ } catch (ignored: InterruptedException) {
76
+ Thread.currentThread().interrupt()
77
+ }
53
78
  }
54
79
 
55
80
  /**
@@ -63,12 +88,13 @@ class OutputSurface : OnFrameAvailableListener {
63
88
  * data is available.
64
89
  */
65
90
  fun awaitNewImage() {
66
- val timeOutMS = 100
91
+ // 10s timeout to avoid spurious failures under heavy main-thread load.
92
+ // The callback now arrives on a dedicated thread, so realistic frames
93
+ // land in <50ms; this bound only catches a stuck pipeline.
94
+ val timeOutMS = 10_000
67
95
  synchronized(mFrameSyncObject) {
68
96
  while (!mFrameAvailable) {
69
97
  try {
70
- // Wait for onFrameAvailable() to signal us. Use a timeout to avoid
71
- // stalling the test if it doesn't arrive.
72
98
  mFrameSyncObject.wait(timeOutMS.toLong())
73
99
  if (!mFrameAvailable) {
74
100
  throw RuntimeException("Surface frame wait timed out")
@@ -100,6 +100,29 @@ class VideoCompressorHelper {
100
100
  ?: 0
101
101
  }
102
102
 
103
+ /**
104
+ * Derive the source video frame rate. METADATA_KEY_CAPTURE_FRAMERATE
105
+ * is only populated for slow-motion captures, so most regular videos
106
+ * return 0 and downstream code falls back to a hard-coded 30 fps —
107
+ * which silently halves the frame count of any 60 fps source and
108
+ * produces visibly choppy output.
109
+ *
110
+ * Strategy: trust CAPTURE_FRAMERATE when present, otherwise compute
111
+ * fps from frame count / duration (API 28+ exposes the frame count
112
+ * via METADATA_KEY_VIDEO_FRAME_COUNT). Returns 0 if neither path
113
+ * yields a usable value.
114
+ */
115
+ fun getSourceFrameRate(metaRetriever: MediaMetadataRetriever): Int {
116
+ val capture = getMetadataInt(metaRetriever, MediaMetadataRetriever.METADATA_KEY_CAPTURE_FRAMERATE)
117
+ if (capture > 0) return capture
118
+
119
+ val frameCount = getMetadataInt(metaRetriever, MediaMetadataRetriever.METADATA_KEY_VIDEO_FRAME_COUNT)
120
+ val durationMs = getMetadataInt(metaRetriever, MediaMetadataRetriever.METADATA_KEY_DURATION)
121
+ if (frameCount <= 0 || durationMs <= 0) return 0
122
+ val fps = (frameCount.toLong() * 1000L / durationMs.toLong()).toInt()
123
+ return fps.coerceIn(0, 240)
124
+ }
125
+
103
126
  fun VideoCompressManual(fileUrl: String?, options: VideoCompressorHelper, promise: Promise, reactContext: ReactApplicationContext?) {
104
127
  try {
105
128
  val uri = Uri.parse(fileUrl)
@@ -110,7 +133,7 @@ class VideoCompressorHelper {
110
133
  val height = getMetadataInt(metaRetriever, MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)
111
134
  val width = getMetadataInt(metaRetriever, MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)
112
135
  val bitrate = getMetadataInt(metaRetriever, MediaMetadataRetriever.METADATA_KEY_BITRATE)
113
- val frameRate = getMetadataInt(metaRetriever, MediaMetadataRetriever.METADATA_KEY_CAPTURE_FRAMERATE)
136
+ val frameRate = getSourceFrameRate(metaRetriever)
114
137
  if (height <= 0 || width <= 0) {
115
138
  promise.reject(Throwable("Failed to read the input video dimensions"))
116
139
  return