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.
- package/android/src/main/java/com/reactnativecompressor/Image/ImageCompressor.kt +41 -21
- package/android/src/main/java/com/reactnativecompressor/Video/AutoVideoCompression.kt +1 -1
- package/android/src/main/java/com/reactnativecompressor/Video/VideoCompressionProfile.kt +21 -11
- package/android/src/main/java/com/reactnativecompressor/Video/VideoCompressor/compressor/Compressor.kt +322 -78
- package/android/src/main/java/com/reactnativecompressor/Video/VideoCompressor/utils/CompressorUtils.kt +24 -5
- package/android/src/main/java/com/reactnativecompressor/Video/VideoCompressor/utils/LocationExtractor.kt +397 -0
- package/android/src/main/java/com/reactnativecompressor/Video/VideoCompressor/videoHelpers/LocationBox.kt +47 -0
- package/android/src/main/java/com/reactnativecompressor/Video/VideoCompressor/videoHelpers/MP4Builder.kt +39 -7
- package/android/src/main/java/com/reactnativecompressor/Video/VideoCompressor/videoHelpers/Mp4Movie.kt +7 -0
- package/android/src/main/java/com/reactnativecompressor/Video/VideoCompressor/videoHelpers/OutputSurface.kt +30 -4
- package/android/src/main/java/com/reactnativecompressor/Video/VideoCompressorHelper.kt +24 -1
- package/ios/Video/VideoMain.swift +27 -11
- package/package.json +1 -1
|
@@ -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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|