diff --git a/android/src/main/java/com/reactnativecompressor/Image/ImageCompressor.kt b/android/src/main/java/com/reactnativecompressor/Image/ImageCompressor.kt index b782fce..f5a2241 100644 --- a/android/src/main/java/com/reactnativecompressor/Image/ImageCompressor.kt +++ b/android/src/main/java/com/reactnativecompressor/Image/ImageCompressor.kt @@ -9,6 +9,7 @@ import android.graphics.Paint import android.media.ExifInterface import android.net.Uri import android.util.Base64 +import android.util.Log import com.facebook.react.bridge.ReactApplicationContext import com.reactnativecompressor.Utils.MediaCache import com.reactnativecompressor.Utils.Utils.exifAttributes @@ -21,6 +22,8 @@ import java.io.IOException import java.net.MalformedURLException object ImageCompressor { + private const val TAG = "ImageCompressor" + fun getRNFileUrl(filePath: String?): String? { var filePath = filePath val returnAbleFile = File(filePath) @@ -56,25 +59,38 @@ object ImageCompressor { return BitmapFactory.decodeFile(filePath) } - fun copyExifInfo(imagePath:String, outputUri:String){ - try { - // for copy exif info - val sourceExif = ExifInterface(imagePath) - val compressedExif = ExifInterface(outputUri) - for (tag in exifAttributes) { - val compressedValue = compressedExif.getAttribute(tag) - if(compressedValue==null) - { - val sourceValue = sourceExif.getAttribute(tag) - if (sourceValue != null) { - compressedExif.setAttribute(tag, sourceValue) + /** + * Strip "file://" / "content://" scheme so legacy ExifInterface can open + * the underlying JPEG. ExifInterface(String) only accepts raw filesystem + * paths — passing a URI string makes it fail silently inside the + * try/catch and drops every EXIF tag, including GPS. + */ + private fun normalizeToFilePath(input: String): String { + if (input.startsWith("file://") || input.startsWith("content://")) { + return Uri.parse(input).path ?: input + } + return input + } + + fun copyExifInfo(imagePath: String, outputUri: String) { + try { + val sourcePath = normalizeToFilePath(imagePath) + val outPath = normalizeToFilePath(outputUri) + val sourceExif = ExifInterface(sourcePath) + val compressedExif = ExifInterface(outPath) + var copied = 0 + for (tag in exifAttributes) { + val sourceValue = sourceExif.getAttribute(tag) ?: continue + if (compressedExif.getAttribute(tag) == null) { + compressedExif.setAttribute(tag, sourceValue) + copied++ + } } - } + compressedExif.saveAttributes() + Log.i(TAG, "copyExifInfo copied $copied tags from $sourcePath -> $outPath") + } catch (e: Exception) { + Log.w(TAG, "copyExifInfo failed for $imagePath", e) } - compressedExif.saveAttributes() - } catch (e: Exception) { - e.printStackTrace() - } } fun encodeImage(imageDataByteArrayOutputStream: ByteArrayOutputStream, isBase64: Boolean, outputExtension: String?,imagePath: String?, reactContext: ReactApplicationContext?): String? { @@ -84,10 +100,14 @@ object ImageCompressor { } else { val outputUri = generateCacheFilePath(outputExtension!!, reactContext!!) try { - val fos = FileOutputStream(outputUri) - imageDataByteArrayOutputStream.writeTo(fos) + // Close the stream before ExifInterface re-opens the file so + // the JPEG bytes are fully flushed; otherwise saveAttributes() + // may truncate the in-flight write. + FileOutputStream(outputUri).use { fos -> + imageDataByteArrayOutputStream.writeTo(fos) + } - copyExifInfo(imagePath!!, outputUri) + copyExifInfo(imagePath!!, outputUri) return getRNFileUrl(outputUri) } catch (e: Exception) { @@ -262,7 +282,7 @@ object ImageCompressor { if (bitmap == null || imagePath == null) return bitmap return try { - val exif = ExifInterface(imagePath) + val exif = ExifInterface(normalizeToFilePath(imagePath)) val orientation = exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL) val matrix = Matrix() diff --git a/android/src/main/java/com/reactnativecompressor/Video/AutoVideoCompression.kt b/android/src/main/java/com/reactnativecompressor/Video/AutoVideoCompression.kt index d8ff8a1..489a08d 100644 --- a/android/src/main/java/com/reactnativecompressor/Video/AutoVideoCompression.kt +++ b/android/src/main/java/com/reactnativecompressor/Video/AutoVideoCompression.kt @@ -24,7 +24,7 @@ object AutoVideoCompression { val actualHeight = VideoCompressorHelper.getMetadataInt(metaRetriever, MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT) val actualWidth = VideoCompressorHelper.getMetadataInt(metaRetriever, MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH) val bitrate = VideoCompressorHelper.getMetadataInt(metaRetriever, MediaMetadataRetriever.METADATA_KEY_BITRATE) - val frameRate = VideoCompressorHelper.getMetadataInt(metaRetriever, MediaMetadataRetriever.METADATA_KEY_CAPTURE_FRAMERATE) + val frameRate = VideoCompressorHelper.getSourceFrameRate(metaRetriever) if (actualHeight <= 0 || actualWidth <= 0) { promise.reject(Throwable("Failed to read the input video dimensions")) return diff --git a/android/src/main/java/com/reactnativecompressor/Video/VideoCompressionProfile.kt b/android/src/main/java/com/reactnativecompressor/Video/VideoCompressionProfile.kt index 8e21cf7..0fb8453 100644 --- a/android/src/main/java/com/reactnativecompressor/Video/VideoCompressionProfile.kt +++ b/android/src/main/java/com/reactnativecompressor/Video/VideoCompressionProfile.kt @@ -12,7 +12,12 @@ data class VideoCompressionProfile( ) object VideoCompressionProfileFactory { + // Fallback when the source frame rate cannot be detected. private const val DEFAULT_FRAME_RATE = 30 + // Hard upper bound. 60 fps covers every modern phone capture (24/25/30/ + // 50/60). Capping at 30 — the previous behaviour — silently halved 60 + // fps recordings and made the output look choppy. + private const val MAX_FRAME_RATE = 60 fun createAuto( sourceWidth: Int, @@ -99,7 +104,7 @@ object VideoCompressionProfileFactory { return DEFAULT_FRAME_RATE } - return sourceFrameRate.coerceIn(1, DEFAULT_FRAME_RATE) + return sourceFrameRate.coerceIn(1, MAX_FRAME_RATE) } private fun estimateBitrate( @@ -111,20 +116,25 @@ object VideoCompressionProfileFactory { targetHeight: Int, targetFrameRate: Int, ): Int { + // WhatsApp-style bitrate envelope. The previous floors/ceilings + // were ~2-3x larger and produced "compressed" outputs that were + // still 20-40 MB for short clips. These bands target ~1.5 Mbps at + // 720p, which matches WhatsApp's typical output size while keeping + // visual quality acceptable for chat playback. val targetLongSide = max(targetWidth, targetHeight) val floor = when { - targetLongSide >= 1920 -> 4_000_000 - targetLongSide >= 1280 -> 2_200_000 - targetLongSide >= 960 -> 1_600_000 - targetLongSide >= 720 -> 1_200_000 - else -> 850_000 + targetLongSide >= 1920 -> 2_000_000 + targetLongSide >= 1280 -> 1_200_000 + targetLongSide >= 960 -> 900_000 + targetLongSide >= 720 -> 700_000 + else -> 500_000 } val ceiling = when { - targetLongSide >= 1920 -> 8_000_000 - targetLongSide >= 1280 -> 5_000_000 - targetLongSide >= 960 -> 3_500_000 - targetLongSide >= 720 -> 2_500_000 - else -> 1_500_000 + targetLongSide >= 1920 -> 3_500_000 + targetLongSide >= 1280 -> 2_000_000 + targetLongSide >= 960 -> 1_500_000 + targetLongSide >= 720 -> 1_200_000 + else -> 900_000 } if (sourceBitrate <= 0) { diff --git a/android/src/main/java/com/reactnativecompressor/Video/VideoCompressor/compressor/Compressor.kt b/android/src/main/java/com/reactnativecompressor/Video/VideoCompressor/compressor/Compressor.kt index d821cc2..ecd5fa3 100644 --- a/android/src/main/java/com/reactnativecompressor/Video/VideoCompressor/compressor/Compressor.kt +++ b/android/src/main/java/com/reactnativecompressor/Video/VideoCompressor/compressor/Compressor.kt @@ -2,6 +2,8 @@ package com.reactnativecompressor.Video.VideoCompressor.compressor import android.content.Context import android.media.MediaCodec +import android.media.MediaCodecInfo +import android.media.MediaCodecList import android.media.MediaExtractor import android.media.MediaFormat import android.media.MediaMetadataRetriever @@ -16,6 +18,7 @@ import com.reactnativecompressor.Video.VideoCompressor.utils.CompressorUtils.pre import com.reactnativecompressor.Video.VideoCompressor.utils.CompressorUtils.printException import com.reactnativecompressor.Video.VideoCompressor.utils.CompressorUtils.setOutputFileParameters import com.reactnativecompressor.Video.VideoCompressor.utils.CompressorUtils.setUpMP4Movie +import com.reactnativecompressor.Video.VideoCompressor.utils.LocationExtractor import com.reactnativecompressor.Video.VideoCompressor.utils.StreamableVideo import com.reactnativecompressor.Video.VideoCompressor.video.InputSurface import com.reactnativecompressor.Video.VideoCompressor.video.MP4Builder @@ -97,6 +100,29 @@ object Compressor { val rotationData = mediaMetadataRetriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION) val bitrateData = mediaMetadataRetriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_BITRATE) val durationData = mediaMetadataRetriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION) + // ISO 6709 string (e.g. "+37.4220-122.0840/"). Forwarded into the + // output udta/©xyz box so GPS metadata survives the rewrite. + // + // Some Samsung firmwares (S10 / Android 12) place "©xyz" in the + // per-track udta, or use a 'loci' box, or iTunes-style meta/keys+ilst. + // MediaMetadataRetriever only reads moov/udta/©xyz — returning null + // (or empty) and dropping GPS. Fall back to a raw MP4 walker that + // scans the whole file for every known location encoding. + val retrievedLocation = + mediaMetadataRetriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_LOCATION) + val locationData = if (!retrievedLocation.isNullOrEmpty()) { + retrievedLocation + } else { + LocationExtractor.extract(context, srcUri) + } + // Never log the resolved ISO 6709 string — it is the user's exact GPS + // coordinates. Log only presence and which mechanism resolved it. + val locationSource = when { + !retrievedLocation.isNullOrEmpty() -> "retriever" + !locationData.isNullOrEmpty() -> "extractor" + else -> "none" + } + Log.i("Compressor", "source location resolved: hasLocation=${!locationData.isNullOrEmpty()} source=$locationSource") // Check if any metadata is missing if (rotationData.isNullOrEmpty() || bitrateData.isNullOrEmpty() || durationData.isNullOrEmpty()) { @@ -142,7 +168,8 @@ object Compressor { extractor, listener, duration, - rotation + rotation, + locationData, ) } @@ -160,31 +187,50 @@ object Compressor { extractor: MediaExtractor, compressionProgressListener: CompressionProgressListener, duration: Long, - rotation: Int + rotation: Int, + location: String?, ): Result { // Check if newWidth and newHeight are valid if (newWidth != 0 && newHeight != 0) { // Create a cache file for the compressed video val cacheFile = File(destination) + // Hoisted so the outer catch can close the muxer even though the val + // below is scoped to the try. Stays null until createMovie() succeeds. + var muxer: MP4Builder? = null + try { // MediaCodec accesses encoder and decoder components and processes the new video // input to generate a compressed/smaller size video val bufferInfo = MediaCodec.BufferInfo() - // Setup mp4 movie - val movie = setUpMP4Movie(rotation, cacheFile) - - // MediaMuxer outputs MP4 in this app - val mediaMuxer = MP4Builder().createMovie(movie) - - // Start with the video track + // Resolve the source video track and its format BEFORE allocating the + // muxer, encoder or EGL surfaces. Dolby Vision profile 5 has no HEVC + // base layer and cannot be transcoded; rejecting it here — instead of + // inside prepareDecoder, after the muxer file stream, encoder and EGL + // surfaces are already live — avoids leaking those resources on bail-out. val videoIndex = findTrack(extractor, isVideo = true) extractor.selectTrack(videoIndex) extractor.seekTo(0, MediaExtractor.SEEK_TO_PREVIOUS_SYNC) val inputFormat = extractor.getTrackFormat(videoIndex) + if (isUnsupportedDolbyVision(inputFormat)) { + runCatching { extractor.release() } + return Result( + id, + success = false, + failureMessage = "Dolby Vision profile 5 has no HEVC base layer; cannot transcode" + ) + } + + // Setup mp4 movie + val movie = setUpMP4Movie(rotation, cacheFile, location) + + // MediaMuxer outputs MP4 in this app + val mediaMuxer = MP4Builder().createMovie(movie) + muxer = mediaMuxer + val outputFormat: MediaFormat = MediaFormat.createVideoFormat(MIME_TYPE, newWidth, newHeight) @@ -196,16 +242,31 @@ object Compressor { outputFrameRate, ) - val decoder: MediaCodec - // Check if QTI hardware acceleration is available val hasQTI = hasQTI() - // Prepare the video encoder - val encoder = prepareEncoder(outputFormat, hasQTI) + // Prepare the video encoder. If the encoder rejects the throughput-tuned + // format at configure() time, prepareEncoder reconfigures using this + // baseline format (same params, no VBR/priority/operating-rate keys). + val encoder = prepareEncoder(outputFormat, hasQTI) { + MediaFormat.createVideoFormat(MIME_TYPE, newWidth, newHeight).also { + setOutputFileParameters( + inputFormat, + it, + newBitrate, + outputFrameRate, + applyThroughputTuning = false, + ) + } + } - val inputSurface: InputSurface - val outputSurface: OutputSurface + // Track pipeline handles as they come up so a failure mid-setup + // (EGL/GL init, decoder configure, encoder.start) releases whatever was + // already created instead of leaking it. The encoder above is always + // non-null by this point. + var decoderRef: MediaCodec? = null + var inputSurfaceRef: InputSurface? = null + var outputSurfaceRef: OutputSurface? = null try { var inputDone = false @@ -213,32 +274,58 @@ object Compressor { var videoTrackIndex = -5 - inputSurface = InputSurface(encoder.createInputSurface()) + // Frame dropping: when source fps is higher than target output fps, + // skip decoded frames whose PTS falls before the next target slot. + // Saves GL render + encoder work proportional to the drop ratio + // (e.g. 60fps → 30fps cuts pipeline work roughly in half). + // + // Only enable dropping when the source frame rate is reliably + // higher than the target. If the source advertises 30 fps and the + // target is 30 fps, even tiny PTS jitter can push a frame just + // before its slot, get it dropped, and turn 30 fps output into + // 20 fps — the choppy playback users reported. + val sourceFrameRate: Int = if (inputFormat.containsKey(MediaFormat.KEY_FRAME_RATE)) { + inputFormat.getInteger(MediaFormat.KEY_FRAME_RATE) + } else 0 + val shouldDropFrames = outputFrameRate > 0 && + sourceFrameRate > 0 && + outputFrameRate < sourceFrameRate + val targetFrameIntervalUs: Long = + if (shouldDropFrames) 1_000_000L / outputFrameRate else 0L + var nextTargetPtsUs: Long = 0L + + val inputSurface = InputSurface(encoder.createInputSurface()) + inputSurfaceRef = inputSurface inputSurface.makeCurrent() // Move to executing state encoder.start() - outputSurface = OutputSurface() + val outputSurface = OutputSurface() + outputSurfaceRef = outputSurface - decoder = prepareDecoder(inputFormat, outputSurface) + val decoder = prepareDecoder(inputFormat, outputSurface) + decoderRef = decoder // Move to executing state decoder.start() while (!outputDone) { if (!inputDone) { - - val index = extractor.sampleTrackIndex - - if (index == videoIndex) { - val inputBufferIndex = - decoder.dequeueInputBuffer(MEDIACODEC_TIMEOUT_DEFAULT) - if (inputBufferIndex >= 0) { + // Feed the decoder until it has no free input slots or the + // extractor is empty. HW codecs typically have 4-8 input + // slots; queuing only one sample per outer iteration starves + // the pipeline and forces serial decode-render-encode. + feedLoop@ while (!inputDone) { + val index = extractor.sampleTrackIndex + + if (index == videoIndex) { + val inputBufferIndex = + decoder.dequeueInputBuffer(0L) + if (inputBufferIndex < 0) break@feedLoop val inputBuffer = decoder.getInputBuffer(inputBufferIndex) val chunkSize = extractor.readSampleData(inputBuffer!!, 0) when { chunkSize < 0 -> { - decoder.queueInputBuffer( inputBufferIndex, 0, @@ -249,7 +336,6 @@ object Compressor { inputDone = true } else -> { - decoder.queueInputBuffer( inputBufferIndex, 0, @@ -258,15 +344,12 @@ object Compressor { 0 ) extractor.advance() - } } - } - - } else if (index == -1) { //end of file - val inputBufferIndex = - decoder.dequeueInputBuffer(MEDIACODEC_TIMEOUT_DEFAULT) - if (inputBufferIndex >= 0) { + } else if (index == -1) { //end of file + val inputBufferIndex = + decoder.dequeueInputBuffer(0L) + if (inputBufferIndex < 0) break@feedLoop decoder.queueInputBuffer( inputBufferIndex, 0, @@ -275,6 +358,9 @@ object Compressor { MediaCodec.BUFFER_FLAG_END_OF_STREAM ) inputDone = true + } else { + // Different track type at head of extractor (audio etc.). + break@feedLoop } } } @@ -353,7 +439,27 @@ object Compressor { } decoderStatus < 0 -> throw RuntimeException("unexpected result from decoder.dequeueOutputBuffer: $decoderStatus") else -> { - val doRender = bufferInfo.size != 0 + val isEos = (bufferInfo.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0 + var doRender = bufferInfo.size != 0 && !isEos + + // Drop frames whose PTS falls before the next target slot. + // Anchor the next slot to the ideal grid (previous slot + + // interval) instead of to the actual PTS — anchoring to PTS + // lets source-side jitter compound into extra drops, which + // collapses the output frame rate well below the target. + if (doRender && targetFrameIntervalUs > 0L) { + if (bufferInfo.presentationTimeUs < nextTargetPtsUs) { + doRender = false + } else { + nextTargetPtsUs += targetFrameIntervalUs + // Snap forward when the source skips past a slot + // (gap, seek, very low source fps) so the gate doesn't + // burst-emit every following frame. + if (bufferInfo.presentationTimeUs >= nextTargetPtsUs) { + nextTargetPtsUs = bufferInfo.presentationTimeUs + targetFrameIntervalUs + } + } + } decoder.releaseOutputBuffer(decoderStatus, doRender) if (doRender) { @@ -392,16 +498,32 @@ object Compressor { } catch (exception: Throwable) { printException(exception) + // Release whatever was initialized before the failure. Setup errors + // (EGL/GL init, decoder configure, encoder.start) and in-loop throws + // land here; without this the encoder + EGL surfaces would leak and + // break the next compression. dispose() tolerates the null handles + // that occur when the failure happens mid-setup. + dispose( + videoIndex, + decoderRef, + encoder, + inputSurfaceRef, + outputSurfaceRef, + extractor + ) + // finishMovie() never runs on this path, so close the MP4Builder + // streams explicitly or the output file handle leaks. + mediaMuxer.close() return Result(id, success = false, failureMessage = exception.message) } // Release resources dispose( videoIndex, - decoder, + decoderRef, encoder, - inputSurface, - outputSurface, + inputSurfaceRef, + outputSurfaceRef, extractor ) @@ -418,18 +540,27 @@ object Compressor { mediaMuxer.finishMovie() } catch (e: Throwable) { printException(e) + // finishMovie() may throw before it closes its own streams; close + // them here so a finalize failure doesn't leak the file handle. + mediaMuxer.close() return Result(id, success = false, failureMessage = e.message ?: "Failed to finalize compressed video") } } catch (exception: Throwable) { printException(exception) + // Covers throws after the inner pipeline closed (e.g. processAudio, + // extractor.release) where the MP4Builder is still open. close() is + // idempotent, so calling it after a successful finishMovie() is a no-op. + muxer?.close() return Result(id, success = false, failureMessage = exception.message) } var resultFile = cacheFile try { - // Keep default outputs browser-compatible by moving the MP4 metadata before media data. + // Keep default outputs browser/progressive-playback compatible by moving the + // MP4 moov atom in front of the media data. This runs for every output; when + // no explicit streamableFile is requested, the rewritten copy replaces cacheFile. val targetFile = streamableFile?.let { File(it) } ?: getStreamableOutputFile(cacheFile) val outputFile = if (targetFile.absolutePath == cacheFile.absolutePath) { getStreamableOutputFile(cacheFile) @@ -547,25 +678,109 @@ object Compressor { } // Function to prepare the video encoder - private fun prepareEncoder(outputFormat: MediaFormat, hasQTI: Boolean): MediaCodec { - - // This seems to cause an issue with certain phones - // val encoderName = MediaCodecList(REGULAR_CODECS).findEncoderForFormat(outputFormat) - // val encoder: MediaCodec = MediaCodec.createByCodecName(encoderName) - // Log.i("encoderName", encoder.name) - // c2.qti.avc.encoder results in a corrupted .mp4 video that does not play in - // Mac and iphones - val encoder = if (hasQTI) { + private fun prepareEncoder( + outputFormat: MediaFormat, + hasQTI: Boolean, + baselineFormatProvider: () -> MediaFormat, + ): MediaCodec { + // Prefer hardware AVC encoder while skipping known-broken QTI codec that + // produces files unplayable on Mac/iOS (c2.qti.avc.encoder). + val encoder = pickAvcEncoder(outputFormat, hasQTI) + try { + encoder.configure( + outputFormat, null, null, + MediaCodec.CONFIGURE_FLAG_ENCODE + ) + Log.i("Compressor", "encoder selected: ${encoder.name}") + return encoder + } catch (e: Exception) { + // Some encoders reject the throughput-tuning keys (VBR bitrate mode, + // priority, operating rate) at configure() time. A codec that throws + // from configure() is unusable, so release it and retry on a fresh + // codec with a baseline format (default rate control) rather than + // failing the whole compression. + Log.w( + "Compressor", + "encoder.configure rejected tuned format; retrying with default settings", + e + ) + runCatching { encoder.release() } + } + + val baseline = baselineFormatProvider() + val fallback = pickAvcEncoder(baseline, hasQTI) + try { + fallback.configure( + baseline, null, null, + MediaCodec.CONFIGURE_FLAG_ENCODE + ) + } catch (e: Exception) { + // Even the baseline format was rejected; release the codec so it + // doesn't leak, then let start()'s outer catch report the failure. + runCatching { fallback.release() } + throw e + } + Log.i("Compressor", "encoder selected (fallback, default rate control): ${fallback.name}") + return fallback + } + + private fun pickAvcEncoder(outputFormat: MediaFormat, hasQTI: Boolean): MediaCodec { + // ALL_CODECS surfaces vendor codecs that REGULAR_CODECS hides (e.g. some + // Exynos / MTK HW encoders). We still filter blacklisted / SW codecs below. + val codecList = MediaCodecList(MediaCodecList.ALL_CODECS) + val candidates = codecList.codecInfos.filter { info -> + info.isEncoder && info.supportedTypes.any { it.equals(MIME_TYPE, ignoreCase = true) } + } + + fun isBlacklisted(name: String): Boolean { + val lower = name.lowercase() + return lower.contains("c2.qti.avc.encoder") || lower.contains("omx.qcom.video.encoder.avc.secure") + } + + fun isSoftware(info: MediaCodecInfo): Boolean { + val name = info.name.lowercase() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + if (info.isSoftwareOnly) return true + } + return name.startsWith("omx.google.") || + name.startsWith("c2.android.") || + name.contains(".sw.") + } + + val supportsFormat = candidates.filter { info -> + runCatching { + info.getCapabilitiesForType(MIME_TYPE).isFormatSupported(outputFormat) + }.getOrDefault(false) && !isBlacklisted(info.name) + } + + val hardwareFirst = supportsFormat.firstOrNull { !isSoftware(it) } + val chosen = hardwareFirst ?: supportsFormat.firstOrNull() + + if (chosen != null) { + return MediaCodec.createByCodecName(chosen.name) + } + + // Fallback: keep historical QTI-safe path when format probing fails. + return if (hasQTI) { MediaCodec.createByCodecName("c2.android.avc.encoder") } else { MediaCodec.createEncoderByType(MIME_TYPE) } - encoder.configure( - outputFormat, null, null, - MediaCodec.CONFIGURE_FLAG_ENCODE - ) + } - return encoder + // Dolby Vision profile 5 (0x20) carries no HEVC base layer, so no standard + // Android decoder can render it. Detect it up front so start() can reject the + // input before allocating the muxer/encoder/EGL surfaces. Profiles 8.x do carry + // an HEVC base layer and are remapped to HEVC in prepareDecoder. + private fun isUnsupportedDolbyVision(inputFormat: MediaFormat): Boolean { + val mime = inputFormat.getString(MediaFormat.KEY_MIME) ?: return false + if (!mime.equals("video/dolby-vision", ignoreCase = true)) return false + val profile = if (inputFormat.containsKey(MediaFormat.KEY_PROFILE)) { + inputFormat.getInteger(MediaFormat.KEY_PROFILE) + } else { + -1 + } + return profile == 0x20 } // Function to prepare the video decoder @@ -573,42 +788,71 @@ object Compressor { inputFormat: MediaFormat, outputSurface: OutputSurface, ): MediaCodec { - // This seems to cause an issue with certain phones - // val decoderName = - // MediaCodecList(REGULAR_CODECS).findDecoderForFormat(inputFormat) - // val decoder = MediaCodec.createByCodecName(decoderName) - // Log.i("decoderName", decoder.name) + val originalMime = inputFormat.getString(MediaFormat.KEY_MIME)!! + + // Dolby Vision (video/dolby-vision) has no standalone decoder on most Android + // devices and throws NAME_NOT_FOUND. Profiles 8.1/8.4 carry an HEVC base layer + // that the standard HEVC decoder can render, so we remap them to HEVC. Profile 5 + // has no compatible base layer; it is rejected by isUnsupportedDolbyVision() in + // start() before any codec/surface is created, so it never reaches here. + val resolvedMime = if (originalMime.equals("video/dolby-vision", ignoreCase = true)) { + inputFormat.setString(MediaFormat.KEY_MIME, MediaFormat.MIMETYPE_VIDEO_HEVC) + MediaFormat.MIMETYPE_VIDEO_HEVC + } else { + originalMime + } - // val decoder = if (hasQTI) { - // MediaCodec.createByCodecName("c2.android.avc.decoder") - //} else { + val decoder = MediaCodec.createDecoderByType(resolvedMime) - val decoder = MediaCodec.createDecoderByType(inputFormat.getString(MediaFormat.KEY_MIME)!!) - //} + try { + decoder.configure(inputFormat, outputSurface.getSurface(), null, 0) + } catch (e: Exception) { + // A codec that throws from configure() is unusable; release it so a + // configure failure here doesn't leak the decoder handle. + runCatching { decoder.release() } + throw e + } - decoder.configure(inputFormat, outputSurface.getSurface(), null, 0) + Log.i("Compressor", "decoder selected: ${decoder.name} mime=$resolvedMime") return decoder } - // Function to release resources + // Function to release resources. + // Every call is wrapped in runCatching so a failure in one teardown step + // does not skip the others (leaking codec handles + GL surfaces). Order: + // detach extractor → stop+release decoder → stop+release encoder → + // release input EGL surface → release output surface (joins its + // HandlerThread). Releasing surfaces last avoids the encoder asking a + // freed EGL surface for buffers during its own shutdown. + // + // decoder / inputSurface / outputSurface are nullable so this also serves the + // partial-init cleanup path, where a setup failure leaves some handles + // uncreated. The encoder is always created before teardown is reachable. private fun dispose( videoIndex: Int, - decoder: MediaCodec, + decoder: MediaCodec?, encoder: MediaCodec, - inputSurface: InputSurface, - outputSurface: OutputSurface, + inputSurface: InputSurface?, + outputSurface: OutputSurface?, extractor: MediaExtractor ) { - extractor.unselectTrack(videoIndex) - - decoder.stop() - decoder.release() - - encoder.stop() - encoder.release() - - inputSurface.release() - outputSurface.release() + runCatching { extractor.unselectTrack(videoIndex) } + .onFailure { Log.w("Compressor", "extractor.unselectTrack failed", it) } + + runCatching { decoder?.stop() } + .onFailure { Log.w("Compressor", "decoder.stop failed", it) } + runCatching { decoder?.release() } + .onFailure { Log.w("Compressor", "decoder.release failed", it) } + + runCatching { encoder.stop() } + .onFailure { Log.w("Compressor", "encoder.stop failed", it) } + runCatching { encoder.release() } + .onFailure { Log.w("Compressor", "encoder.release failed", it) } + + runCatching { inputSurface?.release() } + .onFailure { Log.w("Compressor", "inputSurface.release failed", it) } + runCatching { outputSurface?.release() } + .onFailure { Log.w("Compressor", "outputSurface.release failed", it) } } } diff --git a/android/src/main/java/com/reactnativecompressor/Video/VideoCompressor/utils/CompressorUtils.kt b/android/src/main/java/com/reactnativecompressor/Video/VideoCompressor/utils/CompressorUtils.kt index b6f87e5..801d4a6 100644 --- a/android/src/main/java/com/reactnativecompressor/Video/VideoCompressor/utils/CompressorUtils.kt +++ b/android/src/main/java/com/reactnativecompressor/Video/VideoCompressor/utils/CompressorUtils.kt @@ -49,16 +49,19 @@ object CompressorUtils { } /** - * Set up an Mp4Movie with rotation and cache file. + * Set up an Mp4Movie with rotation, cache file and optional ISO 6709 + * location string forwarded from the source video. */ fun setUpMP4Movie( rotation: Int, cacheFile: File, + location: String? = null, ): Mp4Movie { val movie = Mp4Movie() movie.apply { setCacheFile(cacheFile) setRotation(rotation) + setLocation(location) } return movie } @@ -71,6 +74,7 @@ object CompressorUtils { outputFormat: MediaFormat, newBitrate: Int, targetFrameRate: Int, + applyThroughputTuning: Boolean = true, ) { val newFrameRate = targetFrameRate.coerceAtLeast(1) val iFrameInterval = getIFrameIntervalRate(inputFormat) @@ -84,10 +88,25 @@ object CompressorUtils { setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, iFrameInterval) // Bitrate in bits per second setInteger(MediaFormat.KEY_BIT_RATE, newBitrate) - setInteger( - MediaFormat.KEY_BITRATE_MODE, - MediaCodecInfo.EncoderCapabilities.BITRATE_MODE_CBR - ) + + // Throughput tuning. Some encoders reject these keys at configure() time, + // so the caller drops them (applyThroughputTuning = false) and reconfigures + // with default rate control on a fallback pass — see Compressor.prepareEncoder. + if (applyThroughputTuning) { + // VBR transcodes ~10-20% faster than CBR by skipping rate-control overhead + // on low-motion frames; quality stays equivalent for short-form video. + setInteger( + MediaFormat.KEY_BITRATE_MODE, + MediaCodecInfo.EncoderCapabilities.BITRATE_MODE_VBR + ) + // Hint the hardware codec to run as fast as it can (not throttled to + // realtime playback) and at the highest scheduling priority. These keys + // unlock full throughput on Qualcomm / Exynos / MTK SoCs that accept them. + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.M) { + setInteger(MediaFormat.KEY_PRIORITY, 0) + setInteger(MediaFormat.KEY_OPERATING_RATE, Short.MAX_VALUE.toInt()) + } + } getColorStandard(inputFormat)?.let { setInteger(MediaFormat.KEY_COLOR_STANDARD, it) diff --git a/android/src/main/java/com/reactnativecompressor/Video/VideoCompressor/utils/LocationExtractor.kt b/android/src/main/java/com/reactnativecompressor/Video/VideoCompressor/utils/LocationExtractor.kt new file mode 100644 index 0000000..1049c24 --- /dev/null +++ b/android/src/main/java/com/reactnativecompressor/Video/VideoCompressor/utils/LocationExtractor.kt @@ -0,0 +1,397 @@ +package com.reactnativecompressor.Video.VideoCompressor.utils + +import android.content.Context +import android.net.Uri +import android.os.ParcelFileDescriptor +import android.util.Log +import java.io.File +import java.io.FileInputStream +import java.nio.ByteBuffer +import java.nio.ByteOrder +import java.nio.channels.FileChannel +import java.nio.charset.StandardCharsets +import java.util.Locale + +/** + * Raw MP4 walker that recovers an ISO 6709 GPS string when + * MediaMetadataRetriever.METADATA_KEY_LOCATION fails to return one. + * + * Why this exists: device vendors disagree on where GPS lives. + * - Most phones write Apple "©xyz" under moov/udta or moov/trak/udta. + * - Some ISO-compliant captures use the standard "loci" box. + * - Newer iOS / Android captures use the iTunes-style + * moov/meta/keys + moov/meta/ilst pair with the key + * "com.apple.quicktime.location.ISO6709". + * + * Android's retriever only reads movie-level "©xyz" and silently returns + * null for everything else. The walker descends through every container + * atom and tries each known encoding in priority order. + */ +object LocationExtractor { + + // Share the "Compressor" log tag so the atom dump is visible alongside + // the existing pipeline diagnostics without needing an extra logcat filter. + private const val TAG = "Compressor" + + private val CONTAINER_TYPES = setOf("moov", "trak", "mdia", "minf", "udta", "meta", "ilst") + + fun extract(context: Context, uri: Uri): String? { + Log.i(TAG, "LocationExtractor.extract uri=$uri") + return try { + openChannel(context, uri)?.use { channel -> + Log.i(TAG, "LocationExtractor: file size=${channel.size()}") + val state = WalkState() + walk(channel, 0L, channel.size(), state, depth = 0) + val viaBox = chooseBest(state) + // Log only presence, never the coordinate strings — these are the + // user's exact GPS values and must not land in production logcat. + Log.i( + TAG, + "LocationExtractor box scan: hasXyz=${!state.xyz.isNullOrEmpty()} hasItunesLocation=${!state.itunesLocation.isNullOrEmpty()} hasLoci=${!state.loci.isNullOrEmpty()} hasChosenLocation=${!viaBox.isNullOrEmpty()}" + ) + // Samsung phones (Galaxy S10 / Android 12 verified) write GPS into + // an SEF (Samsung Extended Format) trailer that sits after mdat, + // outside the standard MP4 box hierarchy. The trailer contains + // an ISO 6709 string in plain ASCII. Scan the file tail and let + // the strict regex extract it. + viaBox ?: scanTrailerForIso6709(channel) + } + } catch (e: Exception) { + Log.w(TAG, "LocationExtractor extract failed", e) + null + } + } + + /** + * Open a FileChannel for either a content:// URI (via ContentResolver) or + * a raw filesystem path. JS layer hands the compressor URIs in three + * shapes — content://, file://, and bare /storage/... paths — and the + * latter cannot be opened through ContentResolver. + */ + private fun openChannel(context: Context, uri: Uri): FileChannel? { + val scheme = uri.scheme + if (scheme == null || scheme == "file") { + val path = uri.path ?: uri.toString() + val file = File(path) + if (!file.exists()) { + Log.w(TAG, "LocationExtractor: file does not exist $path") + return null + } + return FileInputStream(file).channel + } + val pfd = context.contentResolver.openFileDescriptor(uri, "r") + if (pfd == null) { + Log.w(TAG, "LocationExtractor: openFileDescriptor returned null for $uri") + return null + } + // AutoCloseInputStream closes the ParcelFileDescriptor when the stream + // is closed. A bare FileInputStream over pfd.fileDescriptor would leak + // the pfd until finalizer runs. + return ParcelFileDescriptor.AutoCloseInputStream(pfd).channel + } + + // Strict ISO 6709 pattern: signed lat, signed lon, optional signed alt, + // mandatory trailing slash. Tight enough that random bytes inside mdat + // virtually never match, lenient enough to accept the small precision + // variations vendors use. + private val ISO6709_REGEX = Regex( + "[+-]\\d{1,3}\\.\\d{2,7}[+-]\\d{1,3}\\.\\d{2,7}([+-]\\d{1,5}(\\.\\d+)?)?/" + ) + + private fun scanTrailerForIso6709(channel: FileChannel): String? { + val size = channel.size() + // 1 MiB tail covers every SEF trailer observed so far. Capped so very + // small clips do not read past start of file. + val tailSize = minOf(size, 1L shl 20).toInt() + if (tailSize <= 0) return null + val start = size - tailSize + val buf = ByteBuffer.allocate(tailSize) + channel.position(start) + if (channel.read(buf) <= 0) return null + buf.flip() + val bytes = ByteArray(buf.remaining()) + buf.get(bytes) + val text = String(bytes, StandardCharsets.ISO_8859_1) + val match = ISO6709_REGEX.find(text) + Log.i(TAG, "LocationExtractor SEF trailer scan matched=${match != null}") + return match?.value + } + + private class WalkState { + var xyz: String? = null + var loci: String? = null + var itunesLocation: String? = null + // iTunes-style meta state. + val itunesKeys: ArrayList = ArrayList() + var insideMeta: Boolean = false + } + + private fun chooseBest(s: WalkState): String? { + return s.xyz?.takeIf { it.isNotEmpty() } + ?: s.itunesLocation?.takeIf { it.isNotEmpty() } + ?: s.loci?.takeIf { it.isNotEmpty() } + } + + private fun walk( + channel: FileChannel, + start: Long, + end: Long, + state: WalkState, + depth: Int, + ) { + var pos = start + val header = ByteBuffer.allocate(16).order(ByteOrder.BIG_ENDIAN) + while (pos + 8 <= end) { + header.clear() + header.limit(8) + channel.position(pos) + if (channel.read(header) < 8) break + header.flip() + val rawSize = header.int.toLong() and 0xFFFFFFFFL + val typeBytes = ByteArray(4) + header.get(typeBytes) + val type = fourCC(typeBytes) + + var headerSize = 8L + var boxSize = rawSize + if (rawSize == 1L) { + val ext = ByteBuffer.allocate(8).order(ByteOrder.BIG_ENDIAN) + channel.position(pos + 8) + if (channel.read(ext) < 8) break + ext.flip() + boxSize = ext.long + headerSize = 16L + } else if (rawSize == 0L) { + boxSize = end - pos + } + + if (boxSize < headerSize || pos + boxSize > end) break + val childEnd = pos + boxSize + val childStart = pos + headerSize + + if (depth < 5) { + // Use Log.i so the atom tree appears in default logcat output and + // can be pasted back when GPS extraction misses a vendor-specific + // box layout. Depth-bounded to avoid spamming on large mdat chunks. + Log.i(TAG, "LocationExtractor atom $type @ $pos size=$boxSize depth=$depth") + } + + when { + // Apple Quicktime "©xyz" - 0xA9 'x' 'y' 'z'. + typeBytes[0] == 0xA9.toByte() && + typeBytes[1] == 'x'.code.toByte() && + typeBytes[2] == 'y'.code.toByte() && + typeBytes[3] == 'z'.code.toByte() -> { + val parsed = readXyz(channel, childStart, childEnd) + if (!parsed.isNullOrEmpty() && state.xyz == null) { + state.xyz = parsed + Log.i(TAG, "found ©xyz") + } + } + + // ISO 14496-12 "loci" location box. + type == "loci" -> { + val parsed = readLoci(channel, childStart, childEnd) + if (!parsed.isNullOrEmpty() && state.loci == null) { + state.loci = parsed + Log.i(TAG, "found loci") + } + } + + // iTunes-style metadata under moov/meta. + type == "keys" && state.insideMeta -> { + parseItunesKeys(channel, childStart, childEnd, state) + } + type == "ilst" && state.insideMeta -> { + parseItunesIlst(channel, childStart, childEnd, state) + } + } + + if (type in CONTAINER_TYPES) { + // "meta" has a 4-byte version+flags prefix before its children. + val innerStart = if (type == "meta") childStart + 4 else childStart + val priorMeta = state.insideMeta + if (type == "meta") state.insideMeta = true + walk(channel, innerStart, childEnd, state, depth + 1) + state.insideMeta = priorMeta + } + + pos = childEnd + } + } + + private fun fourCC(b: ByteArray): String { + val sb = StringBuilder(4) + for (byte in b) { + val c = byte.toInt() and 0xFF + sb.append(if (c in 0x20..0x7E) c.toChar() else '?') + } + return sb.toString() + } + + private fun readBoxContent(channel: FileChannel, start: Long, end: Long): ByteBuffer? { + val len = (end - start).toInt() + if (len <= 0) return null + val buf = ByteBuffer.allocate(len).order(ByteOrder.BIG_ENDIAN) + channel.position(start) + if (channel.read(buf) < len) return null + buf.flip() + return buf + } + + /** + * Apple "©xyz" content: + * uint16 length + * uint16 language code (packed) + * bytes ISO 6709 string + */ + private fun readXyz(channel: FileChannel, start: Long, end: Long): String? { + val buf = readBoxContent(channel, start, end) ?: return null + if (buf.remaining() < 4) return null + val len = buf.short.toInt() and 0xFFFF + buf.short + val take = minOf(len, buf.remaining()) + if (take <= 0) return null + val bytes = ByteArray(take) + buf.get(bytes) + return String(bytes, StandardCharsets.UTF_8).trim().ifEmpty { null } + } + + /** + * ISO 14496-12 "loci" content: + * uint8 version + * uint24 flags + * uint16 language + * utf8z name + * uint8 role + * uint32 longitude (16.16 fixed) + * uint32 latitude (16.16 fixed) + * uint32 altitude (16.16 fixed) + * ... + */ + private fun readLoci(channel: FileChannel, start: Long, end: Long): String? { + val buf = readBoxContent(channel, start, end) ?: return null + if (buf.remaining() < 6) return null + buf.int // version + flags + buf.short // language + // Skip null-terminated name. + while (buf.hasRemaining() && buf.get() != 0.toByte()) { /* skip */ } + if (buf.remaining() < 1 + 12) return null + buf.get() // role + val longitude = fixedPoint1616(buf.int) + val latitude = fixedPoint1616(buf.int) + val altitude = fixedPoint1616(buf.int) + return formatIso6709(latitude, longitude, altitude) + } + + private fun fixedPoint1616(raw: Int): Double { + return raw.toDouble() / 65536.0 + } + + private fun formatIso6709(lat: Double, lon: Double, alt: Double): String { + val sb = StringBuilder() + sb.append(if (lat >= 0) "+" else "") + sb.append(String.format(Locale.US, "%.4f", lat)) + sb.append(if (lon >= 0) "+" else "") + sb.append(String.format(Locale.US, "%.4f", lon)) + if (alt != 0.0) { + sb.append(if (alt >= 0) "+" else "") + sb.append(String.format(Locale.US, "%.3f", alt)) + } + sb.append('/') + return sb.toString() + } + + /** + * Apple iTunes-style "keys" box content: + * uint32 version+flags + * uint32 entry_count + * for each: + * uint32 key_size (includes header) + * uint32 key_namespace ('mdta') + * bytes key_value (utf-8) + */ + private fun parseItunesKeys(channel: FileChannel, start: Long, end: Long, state: WalkState) { + val buf = readBoxContent(channel, start, end) ?: return + state.itunesKeys.clear() + if (buf.remaining() < 8) return + buf.int // version + flags + val count = buf.int + for (i in 0 until count) { + if (buf.remaining() < 8) break + val entrySize = buf.int + buf.int // namespace + val keyLen = entrySize - 8 + if (keyLen <= 0 || keyLen > buf.remaining()) break + val keyBytes = ByteArray(keyLen) + buf.get(keyBytes) + state.itunesKeys.add(String(keyBytes, StandardCharsets.UTF_8)) + } + Log.i(TAG, "LocationExtractor itunes keys: ${state.itunesKeys}") + } + + /** + * Apple iTunes-style "ilst" box. Each child is an indexed item whose + * type is a uint32 index pointing back into the "keys" table. Inside + * each item is a "data" sub-box with the actual payload. + */ + private fun parseItunesIlst(channel: FileChannel, start: Long, end: Long, state: WalkState) { + var pos = start + val header = ByteBuffer.allocate(8).order(ByteOrder.BIG_ENDIAN) + while (pos + 8 <= end) { + header.clear() + channel.position(pos) + if (channel.read(header) < 8) break + header.flip() + val itemSize = header.int.toLong() and 0xFFFFFFFFL + val indexBytes = ByteArray(4) + header.get(indexBytes) + val index = ByteBuffer.wrap(indexBytes).order(ByteOrder.BIG_ENDIAN).int + if (itemSize < 8 || pos + itemSize > end) break + val itemEnd = pos + itemSize + val key = state.itunesKeys.getOrNull(index - 1) + if (key == "com.apple.quicktime.location.ISO6709") { + val payload = findItunesData(channel, pos + 8, itemEnd) + if (!payload.isNullOrEmpty() && state.itunesLocation == null) { + state.itunesLocation = payload + Log.i(TAG, "found itunes location") + } + } + pos = itemEnd + } + } + + private fun findItunesData(channel: FileChannel, start: Long, end: Long): String? { + var pos = start + val header = ByteBuffer.allocate(8).order(ByteOrder.BIG_ENDIAN) + while (pos + 8 <= end) { + header.clear() + channel.position(pos) + if (channel.read(header) < 8) break + header.flip() + val size = header.int.toLong() and 0xFFFFFFFFL + val typeBytes = ByteArray(4) + header.get(typeBytes) + val type = fourCC(typeBytes) + if (size < 8 || pos + size > end) break + if (type == "data") { + // data box: uint32 type indicator, uint32 locale, then payload. + val payloadStart = pos + 8 + 8 + val payloadEnd = pos + size + if (payloadEnd > payloadStart) { + val len = (payloadEnd - payloadStart).toInt() + val buf = ByteBuffer.allocate(len) + channel.position(payloadStart) + if (channel.read(buf) >= len) { + buf.flip() + val bytes = ByteArray(len) + buf.get(bytes) + return String(bytes, StandardCharsets.UTF_8).trim().ifEmpty { null } + } + } + } + pos += size + } + return null + } +} diff --git a/android/src/main/java/com/reactnativecompressor/Video/VideoCompressor/videoHelpers/LocationBox.kt b/android/src/main/java/com/reactnativecompressor/Video/VideoCompressor/videoHelpers/LocationBox.kt new file mode 100644 index 0000000..a954da9 --- /dev/null +++ b/android/src/main/java/com/reactnativecompressor/Video/VideoCompressor/videoHelpers/LocationBox.kt @@ -0,0 +1,47 @@ +package com.reactnativecompressor.Video.VideoCompressor.video + +import org.mp4parser.support.AbstractBox +import java.nio.ByteBuffer +import java.nio.charset.StandardCharsets + +/** + * Apple Quicktime "©xyz" box that stores an ISO 6709 location string + * (e.g. "+37.4220-122.0840/" or "+37.4220-122.0840+009.000/"). + * + * Android's MediaMetadataRetriever.METADATA_KEY_LOCATION reads this exact + * box; writing it preserves GPS metadata across the compression rewrite. + * + * Layout of the box content: + * uint16 BE text byte length + * uint16 BE language packed code (0x15c7 = "und") + * bytes ISO 6709 string (no NUL terminator) + */ +class LocationBox : AbstractBox(TYPE) { + + var location: String = "" + + override fun getContentSize(): Long { + val bytes = location.toByteArray(StandardCharsets.UTF_8) + return (2 + 2 + bytes.size).toLong() + } + + override fun _parseDetails(content: ByteBuffer) { + val len = content.short.toInt() and 0xFFFF + content.short + val bytes = ByteArray(len) + content.get(bytes) + location = String(bytes, StandardCharsets.UTF_8) + } + + override fun getContent(byteBuffer: ByteBuffer) { + val bytes = location.toByteArray(StandardCharsets.UTF_8) + byteBuffer.putShort(bytes.size.toShort()) + byteBuffer.putShort(LANG_UND) + byteBuffer.put(bytes) + } + + companion object { + const val TYPE = "©xyz" + private const val LANG_UND: Short = 0x15c7 + } +} diff --git a/android/src/main/java/com/reactnativecompressor/Video/VideoCompressor/videoHelpers/MP4Builder.kt b/android/src/main/java/com/reactnativecompressor/Video/VideoCompressor/videoHelpers/MP4Builder.kt index db6cb49..5db41ea 100644 --- a/android/src/main/java/com/reactnativecompressor/Video/VideoCompressor/videoHelpers/MP4Builder.kt +++ b/android/src/main/java/com/reactnativecompressor/Video/VideoCompressor/videoHelpers/MP4Builder.kt @@ -30,13 +30,20 @@ class MP4Builder { fos = FileOutputStream(mp4Movie.getCacheFile()) fc = fos.channel - val fileTypeBox: FileTypeBox = createFileTypeBox() - fileTypeBox.getBox(fc) - dataOffset += fileTypeBox.size - wroteSinceLastMdat = dataOffset - - mdat = Mdat() - sizeBuffer = ByteBuffer.allocateDirect(4) + // Streams are open now; if header writing throws, the caller never gets + // a reference to close, so release them here before rethrowing. + try { + val fileTypeBox: FileTypeBox = createFileTypeBox() + fileTypeBox.getBox(fc) + dataOffset += fileTypeBox.size + wroteSinceLastMdat = dataOffset + + mdat = Mdat() + sizeBuffer = ByteBuffer.allocateDirect(4) + } catch (e: Exception) { + close() + throw e + } return this } @@ -131,6 +138,19 @@ class MP4Builder { fos.close() } + // Close the underlying file streams without finalizing the movie. Used on + // failure paths where finishMovie() never runs, so the FileOutputStream and + // its FileChannel opened in createMovie() don't leak. Safe to call when + // createMovie() failed early (streams not yet initialized) and idempotent. + fun close() { + if (::fc.isInitialized) { + runCatching { fc.close() } + } + if (::fos.isInitialized) { + runCatching { fos.close() } + } + } + private fun createFileTypeBox(): FileTypeBox { // completed list can be found at https://www.ftyps.com/ val minorBrands = listOf( @@ -190,6 +210,18 @@ class MP4Builder { movieBox.addBox(createTrackBox(track, movie)) } + // Preserve source GPS metadata. MediaMetadataRetriever and most + // gallery apps read the Apple "©xyz" box inside moov/udta, so any + // ISO 6709 string passed in is written there verbatim. + val location = movie.getLocation() + if (!location.isNullOrEmpty()) { + val udta = UserDataBox() + val xyz = LocationBox() + xyz.location = location + udta.addBox(xyz) + movieBox.addBox(udta) + } + return movieBox } diff --git a/android/src/main/java/com/reactnativecompressor/Video/VideoCompressor/videoHelpers/Mp4Movie.kt b/android/src/main/java/com/reactnativecompressor/Video/VideoCompressor/videoHelpers/Mp4Movie.kt index 84dbbfc..189955a 100644 --- a/android/src/main/java/com/reactnativecompressor/Video/VideoCompressor/videoHelpers/Mp4Movie.kt +++ b/android/src/main/java/com/reactnativecompressor/Video/VideoCompressor/videoHelpers/Mp4Movie.kt @@ -11,6 +11,7 @@ class Mp4Movie { private var matrix = Matrix.ROTATE_0 private val tracks = ArrayList() private var cacheFile: File? = null + private var location: String? = null fun getMatrix(): Matrix? = matrix @@ -18,6 +19,12 @@ class Mp4Movie { cacheFile = file } + fun setLocation(value: String?) { + location = value + } + + fun getLocation(): String? = location + fun setRotation(angle: Int) { when (angle) { 0 -> { diff --git a/android/src/main/java/com/reactnativecompressor/Video/VideoCompressor/videoHelpers/OutputSurface.kt b/android/src/main/java/com/reactnativecompressor/Video/VideoCompressor/videoHelpers/OutputSurface.kt index 6222859..74a930d 100644 --- a/android/src/main/java/com/reactnativecompressor/Video/VideoCompressor/videoHelpers/OutputSurface.kt +++ b/android/src/main/java/com/reactnativecompressor/Video/VideoCompressor/videoHelpers/OutputSurface.kt @@ -2,6 +2,8 @@ package com.reactnativecompressor.Video.VideoCompressor.video import android.graphics.SurfaceTexture import android.graphics.SurfaceTexture.OnFrameAvailableListener +import android.os.Handler +import android.os.HandlerThread import android.view.Surface class OutputSurface : OnFrameAvailableListener { @@ -12,6 +14,15 @@ class OutputSurface : OnFrameAvailableListener { private var mFrameAvailable = false private var mTextureRender: TextureRenderer? = null + // Dedicated thread for SurfaceTexture's onFrameAvailable callback. + // Without this, Android delivers the callback on the main UI thread + // (because the compression coroutine has no Looper), so awaitNewImage() + // stalls whenever the main thread is busy with UI / JS bridge work. + // Routing the callback to its own thread removes that contention and + // is the single biggest throughput win for the decoder→encoder pipeline. + private val mCallbackThread = HandlerThread("CompressorSurfaceTexCb").apply { start() } + private val mCallbackHandler = Handler(mCallbackThread.looper) + /** * Creates an OutputSurface using the current EGL context. This Surface will be * passed to MediaCodec.configure(). @@ -35,7 +46,7 @@ class OutputSurface : OnFrameAvailableListener { // causes the native finalizer to run. mSurfaceTexture = SurfaceTexture(it.getTextureId()) mSurfaceTexture?.let { surfaceTexture -> - surfaceTexture.setOnFrameAvailableListener(this) + surfaceTexture.setOnFrameAvailableListener(this, mCallbackHandler) mSurface = Surface(mSurfaceTexture) } } @@ -43,6 +54,13 @@ class OutputSurface : OnFrameAvailableListener { /** * Discards all resources held by this class, notably the EGL context. + * + * quitSafely() returns immediately; the HandlerThread's native pthread + * may still be terminating when callers proceed to tear down MediaCodec. + * If the ART sampling profiler walks threads during that window it can + * dereference a stale pthread_t and SIGABRT. join(500) blocks until the + * thread is fully exited (pthread_join) so the pthread_t is no longer + * tracked. Bounded at 500ms to avoid hanging on a pathological looper. */ fun release() { mSurface?.release() @@ -50,6 +68,13 @@ class OutputSurface : OnFrameAvailableListener { mTextureRender = null mSurface = null mSurfaceTexture = null + + mCallbackThread.quitSafely() + try { + mCallbackThread.join(500) + } catch (ignored: InterruptedException) { + Thread.currentThread().interrupt() + } } /** @@ -63,12 +88,13 @@ class OutputSurface : OnFrameAvailableListener { * data is available. */ fun awaitNewImage() { - val timeOutMS = 100 + // 10s timeout to avoid spurious failures under heavy main-thread load. + // The callback now arrives on a dedicated thread, so realistic frames + // land in <50ms; this bound only catches a stuck pipeline. + val timeOutMS = 10_000 synchronized(mFrameSyncObject) { while (!mFrameAvailable) { try { - // Wait for onFrameAvailable() to signal us. Use a timeout to avoid - // stalling the test if it doesn't arrive. mFrameSyncObject.wait(timeOutMS.toLong()) if (!mFrameAvailable) { throw RuntimeException("Surface frame wait timed out") diff --git a/android/src/main/java/com/reactnativecompressor/Video/VideoCompressorHelper.kt b/android/src/main/java/com/reactnativecompressor/Video/VideoCompressorHelper.kt index cfa5622..6669a27 100644 --- a/android/src/main/java/com/reactnativecompressor/Video/VideoCompressorHelper.kt +++ b/android/src/main/java/com/reactnativecompressor/Video/VideoCompressorHelper.kt @@ -100,6 +100,29 @@ class VideoCompressorHelper { ?: 0 } + /** + * Derive the source video frame rate. METADATA_KEY_CAPTURE_FRAMERATE + * is only populated for slow-motion captures, so most regular videos + * return 0 and downstream code falls back to a hard-coded 30 fps — + * which silently halves the frame count of any 60 fps source and + * produces visibly choppy output. + * + * Strategy: trust CAPTURE_FRAMERATE when present, otherwise compute + * fps from frame count / duration (API 28+ exposes the frame count + * via METADATA_KEY_VIDEO_FRAME_COUNT). Returns 0 if neither path + * yields a usable value. + */ + fun getSourceFrameRate(metaRetriever: MediaMetadataRetriever): Int { + val capture = getMetadataInt(metaRetriever, MediaMetadataRetriever.METADATA_KEY_CAPTURE_FRAMERATE) + if (capture > 0) return capture + + val frameCount = getMetadataInt(metaRetriever, MediaMetadataRetriever.METADATA_KEY_VIDEO_FRAME_COUNT) + val durationMs = getMetadataInt(metaRetriever, MediaMetadataRetriever.METADATA_KEY_DURATION) + if (frameCount <= 0 || durationMs <= 0) return 0 + val fps = (frameCount.toLong() * 1000L / durationMs.toLong()).toInt() + return fps.coerceIn(0, 240) + } + fun VideoCompressManual(fileUrl: String?, options: VideoCompressorHelper, promise: Promise, reactContext: ReactApplicationContext?) { try { val uri = Uri.parse(fileUrl) @@ -110,7 +133,7 @@ class VideoCompressorHelper { val height = getMetadataInt(metaRetriever, MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT) val width = getMetadataInt(metaRetriever, MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH) val bitrate = getMetadataInt(metaRetriever, MediaMetadataRetriever.METADATA_KEY_BITRATE) - val frameRate = getMetadataInt(metaRetriever, MediaMetadataRetriever.METADATA_KEY_CAPTURE_FRAMERATE) + val frameRate = getSourceFrameRate(metaRetriever) if (height <= 0 || width <= 0) { promise.reject(Throwable("Failed to read the input video dimensions")) return diff --git a/ios/Video/VideoMain.swift b/ios/Video/VideoMain.swift index 09f44e6..3e29497 100644 --- a/ios/Video/VideoMain.swift +++ b/ios/Video/VideoMain.swift @@ -164,7 +164,9 @@ class VideoCompressor { return 30 } - return min(max(nominalFrameRate, 1), 30) + // Cap at 60 fps. 30 fps cap silently halved 60 fps recordings and + // produced visibly choppy output. + return min(max(nominalFrameRate, 1), 60) } func scaledDimensions(width: CGFloat, height: CGFloat, maxSize: CGFloat) -> (width: Int, height: Int) { @@ -193,26 +195,32 @@ class VideoCompressor { targetHeight: Int, targetFrameRate: Int ) -> Int { + // WhatsApp-style bitrate envelope. The previous floors/ceilings were + // ~2-3x larger and produced "compressed" outputs that were still + // 20-40 MB for short clips. These bands target ~1.5 Mbps at 720p, + // matching WhatsApp's typical output size while keeping visual + // quality acceptable for chat playback. Must stay in sync with + // VideoCompressionProfile.kt on Android. let targetLongSide = max(targetWidth, targetHeight) let floor: Int let ceiling: Int switch targetLongSide { case 1920...: - floor = 4_000_000 - ceiling = 8_000_000 + floor = 2_000_000 + ceiling = 3_500_000 case 1280...1919: - floor = 2_200_000 - ceiling = 5_000_000 + floor = 1_200_000 + ceiling = 2_000_000 case 960...1279: - floor = 1_600_000 - ceiling = 3_500_000 + floor = 900_000 + ceiling = 1_500_000 case 720...959: - floor = 1_200_000 - ceiling = 2_500_000 + floor = 700_000 + ceiling = 1_200_000 default: - floor = 850_000 - ceiling = 1_500_000 + floor = 500_000 + ceiling = 900_000 } guard originalBitrate > 0 else { @@ -336,6 +344,14 @@ class VideoCompressor { ] } + // Preserve source metadata (location, creation date, etc.) by forwarding + // every available metadata format to the writer. + var preservedMetadata: [AVMetadataItem] = asset.metadata + for format in asset.availableMetadataFormats { + preservedMetadata.append(contentsOf: asset.metadata(forFormat: format)) + } + exporter.metadata = preservedMetadata + compressorExports[uuid] = exporter exporter.export(progressHandler: { (progress) in let roundProgress:Int=Int((progress*100).rounded());