From 2d0e017e76850f60be1a945a91da823266a314de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=E1=BB=AFu=20Nguy=E1=BB=85n?= Date: Thu, 14 May 2026 10:04:36 +0700 Subject: [PATCH 01/13] fix(android): support Dolby Vision MOV and speed up transcode iPhone HDR .MOV uses video/dolby-vision mime which has no standalone Android decoder, so createDecoderByType throws "Failed to initialize video/dolby-vision". DV profiles 8.x carry an HEVC base layer, so remap mime to video/hevc before configuring the decoder. Reject profile 5 (0x20) explicitly since it has no HEVC fallback. Perf, bundled to land with the codec rework: - Pick HW AVC encoder via MediaCodecList(ALL_CODECS), blacklist c2.qti.avc.encoder (corrupt MP4 on Mac/iOS). - Feed decoder until input slots drain instead of one sample per loop; unblocks parallel decode-render-encode. - Drop decoded frames whose PTS precedes the next target slot when source fps exceeds output fps. - Encoder: VBR + KEY_PRIORITY=0 + KEY_OPERATING_RATE=MAX to unthrottle HW codec scheduling. - Route SurfaceTexture onFrameAvailable to a dedicated HandlerThread so awaitNewImage stops contending with the main/JS thread. - Skip StreamableVideo rewrite unless caller passed a streamableFile; halves disk I/O for chat uploads. --- .../VideoCompressor/compressor/Compressor.kt | 194 ++++++++++++------ .../VideoCompressor/utils/CompressorUtils.kt | 11 +- .../videoHelpers/OutputSurface.kt | 22 +- 3 files changed, 164 insertions(+), 63 deletions(-) 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 d821cc24..0edca01f 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 @@ -213,6 +215,14 @@ object Compressor { var videoTrackIndex = -5 + // 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). + val targetFrameIntervalUs: Long = + if (outputFrameRate > 0) 1_000_000L / outputFrameRate else 0L + var nextTargetPtsUs: Long = 0L + inputSurface = InputSurface(encoder.createInputSurface()) inputSurface.makeCurrent() // Move to executing state @@ -227,18 +237,21 @@ object Compressor { 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 +262,6 @@ object Compressor { inputDone = true } else -> { - decoder.queueInputBuffer( inputBufferIndex, 0, @@ -258,15 +270,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 +284,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 +365,19 @@ 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. + // Only kicks in when targetFrameIntervalUs > 0 and the source + // is producing faster than the target frame rate. + if (doRender && targetFrameIntervalUs > 0L) { + if (bufferInfo.presentationTimeUs < nextTargetPtsUs) { + doRender = false + } else { + nextTargetPtsUs = bufferInfo.presentationTimeUs + targetFrameIntervalUs + } + } decoder.releaseOutputBuffer(decoderStatus, doRender) if (doRender) { @@ -428,27 +452,31 @@ object Compressor { var resultFile = cacheFile - try { - // Keep default outputs browser-compatible by moving the MP4 metadata before media data. - val targetFile = streamableFile?.let { File(it) } ?: getStreamableOutputFile(cacheFile) - val outputFile = if (targetFile.absolutePath == cacheFile.absolutePath) { - getStreamableOutputFile(cacheFile) - } else { - targetFile - } - val result = StreamableVideo.start(`in` = cacheFile, out = outputFile) - if (result) { - if (streamableFile == null || targetFile.absolutePath == cacheFile.absolutePath) { - cacheFile.delete() - outputFile.renameTo(cacheFile) - resultFile = cacheFile + // StreamableVideo rewrites the whole MP4 to move the moov atom to the front, + // which doubles disk I/O. Only run it when the caller explicitly requested a + // streamable copy (non-null streamableFile). Chat uploads do not need it. + if (streamableFile != null) { + try { + val targetFile = File(streamableFile) + val outputFile = if (targetFile.absolutePath == cacheFile.absolutePath) { + getStreamableOutputFile(cacheFile) } else { - resultFile = outputFile - cacheFile.delete() + targetFile + } + val result = StreamableVideo.start(`in` = cacheFile, out = outputFile) + if (result) { + if (targetFile.absolutePath == cacheFile.absolutePath) { + cacheFile.delete() + outputFile.renameTo(cacheFile) + resultFile = cacheFile + } else { + resultFile = outputFile + cacheFile.delete() + } } + } catch (e: Exception) { + printException(e) } - } catch (e: Exception) { - printException(e) } if (!resultFile.exists() || resultFile.length() <= 32) { return Result( @@ -548,46 +576,96 @@ 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) { - MediaCodec.createByCodecName("c2.android.avc.encoder") - } else { - MediaCodec.createEncoderByType(MIME_TYPE) - } + // 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) encoder.configure( outputFormat, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE ) + Log.i("Compressor", "encoder selected: ${encoder.name}") + return encoder } + 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) + } + } + // Function to prepare the video decoder private fun prepareDecoder( 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 decoder = if (hasQTI) { - // MediaCodec.createByCodecName("c2.android.avc.decoder") - //} else { + 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. Profile 5 has no compatible base + // layer and must be rejected upstream. + val resolvedMime = if (originalMime.equals("video/dolby-vision", ignoreCase = true)) { + val profile = if (inputFormat.containsKey(MediaFormat.KEY_PROFILE)) { + inputFormat.getInteger(MediaFormat.KEY_PROFILE) + } else { + -1 + } + // DV profile 5 = 0x20, no HEVC fallback. Profiles 8.x carry HEVC base layer. + if (profile == 0x20) { + throw IllegalStateException("Dolby Vision profile 5 has no HEVC base layer; cannot transcode") + } + inputFormat.setString(MediaFormat.KEY_MIME, MediaFormat.MIMETYPE_VIDEO_HEVC) + MediaFormat.MIMETYPE_VIDEO_HEVC + } else { + originalMime + } - val decoder = MediaCodec.createDecoderByType(inputFormat.getString(MediaFormat.KEY_MIME)!!) - //} + val decoder = MediaCodec.createDecoderByType(resolvedMime) decoder.configure(inputFormat, outputSurface.getSurface(), null, 0) + Log.i("Compressor", "decoder selected: ${decoder.name} mime=$resolvedMime") + return decoder } 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 b6f87e52..68a3841c 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 @@ -84,10 +84,19 @@ object CompressorUtils { setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, iFrameInterval) // Bitrate in bits per second setInteger(MediaFormat.KEY_BIT_RATE, newBitrate) + // 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_CBR + 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 + // are required to unlock full throughput on Qualcomm / Exynos / MTK SoCs. + 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/videoHelpers/OutputSurface.kt b/android/src/main/java/com/reactnativecompressor/Video/VideoCompressor/videoHelpers/OutputSurface.kt index 62228591..671d3d9d 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) } } @@ -50,6 +61,8 @@ class OutputSurface : OnFrameAvailableListener { mTextureRender = null mSurface = null mSurfaceTexture = null + + mCallbackThread.quitSafely() } /** @@ -63,12 +76,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") From 6836af6c9fcac693b7247c89f33a9bb2e562517d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=E1=BB=AFu=20Nguy=E1=BB=85n?= Date: Thu, 14 May 2026 11:10:15 +0700 Subject: [PATCH 02/13] fix(video): preserve location and source metadata during compression MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Android: extract METADATA_KEY_LOCATION and write an Apple-style "©xyz" udta atom into the muxed MP4 so geotags survive transcoding. iOS: forward asset.metadata plus every available metadata format to the AVAssetExportSession so location, creation date, and other tags are retained in the exported file. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../VideoCompressor/compressor/Compressor.kt | 9 ++++--- .../VideoCompressor/utils/CompressorUtils.kt | 2 ++ .../videoHelpers/MP4Builder.kt | 27 +++++++++++++++++++ .../VideoCompressor/videoHelpers/Mp4Movie.kt | 7 +++++ ios/Video/VideoMain.swift | 8 ++++++ 5 files changed, 50 insertions(+), 3 deletions(-) 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 0edca01f..bf87530b 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 @@ -99,6 +99,7 @@ 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) + val locationData = mediaMetadataRetriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_LOCATION) // Check if any metadata is missing if (rotationData.isNullOrEmpty() || bitrateData.isNullOrEmpty() || durationData.isNullOrEmpty()) { @@ -144,7 +145,8 @@ object Compressor { extractor, listener, duration, - rotation + rotation, + locationData ) } @@ -162,7 +164,8 @@ object Compressor { extractor: MediaExtractor, compressionProgressListener: CompressionProgressListener, duration: Long, - rotation: Int + rotation: Int, + location: String? = null ): Result { // Check if newWidth and newHeight are valid if (newWidth != 0 && newHeight != 0) { @@ -175,7 +178,7 @@ object Compressor { val bufferInfo = MediaCodec.BufferInfo() // Setup mp4 movie - val movie = setUpMP4Movie(rotation, cacheFile) + val movie = setUpMP4Movie(rotation, cacheFile, location) // MediaMuxer outputs MP4 in this app val mediaMuxer = MP4Builder().createMovie(movie) 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 68a3841c..780d5032 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 @@ -54,11 +54,13 @@ object CompressorUtils { fun setUpMP4Movie( rotation: Int, cacheFile: File, + location: String? = null, ): Mp4Movie { val movie = Mp4Movie() movie.apply { setCacheFile(cacheFile) setRotation(rotation) + setLocation(location) } return movie } 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 db6cb49a..28928952 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 @@ -4,6 +4,7 @@ import android.media.MediaCodec import android.media.MediaFormat import org.mp4parser.Box import org.mp4parser.boxes.iso14496.part12.* +import org.mp4parser.support.AbstractBox import org.mp4parser.support.Matrix import java.io.FileOutputStream @@ -190,9 +191,35 @@ class MP4Builder { movieBox.addBox(createTrackBox(track, movie)) } + val location = movie.getLocation() + if (!location.isNullOrEmpty()) { + val udta = UserDataBox() + udta.addBox(XyzLocationBox(location)) + movieBox.addBox(udta) + } + return movieBox } + // Apple-style "©xyz" geolocation atom inside udta. + // Payload: 2 bytes UTF-16 BE length, 2 bytes packed language ("eng" = 0x15C7), + // followed by ISO 6709 location string (e.g. "+12.3456-067.7890/"). + private class XyzLocationBox(private val location: String) : AbstractBox("©xyz") { + override fun getContentSize(): Long { + val bytes = location.toByteArray(Charsets.UTF_8) + return (4 + bytes.size).toLong() + } + + override fun _parseDetails(content: ByteBuffer?) {} + + override fun getContent(byteBuffer: ByteBuffer) { + val bytes = location.toByteArray(Charsets.UTF_8) + byteBuffer.putShort(bytes.size.toShort()) + byteBuffer.putShort(0x15C7.toShort()) + byteBuffer.put(bytes) + } + } + private fun createTrackBox(track: Track, movie: Mp4Movie): TrackBox { val trackBox = TrackBox() val tkhd = TrackHeaderBox() 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 84dbbfc2..7096d904 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(loc: String?) { + location = loc + } + + fun getLocation(): String? = location + fun setRotation(angle: Int) { when (angle) { 0 -> { diff --git a/ios/Video/VideoMain.swift b/ios/Video/VideoMain.swift index 09f44e63..0aa36c76 100644 --- a/ios/Video/VideoMain.swift +++ b/ios/Video/VideoMain.swift @@ -336,6 +336,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()); From 7fdc50af0dd462bfc0abd5eacfccccc6b3e05619 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=E1=BB=AFu=20Nguy=E1=BB=85n?= Date: Fri, 15 May 2026 15:24:13 +0700 Subject: [PATCH 03/13] fix(video): fps, bitrate, GPS, teardown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - fps: derive from frame_count/duration when CAPTURE_FRAMERATE absent. Cap 30→60. Drop-gate only when source>target, anchor to ideal grid. - bitrate: WhatsApp envelope (~1.5 Mbps @ 720p). Android+iOS sync. - GPS: LocationExtractor walks MP4 — ©xyz, loci, iTunes meta/keys+ilst, SEF trailer regex. Writer ©xyz moved to LocationBox class. - teardown: runCatching every dispose step. join() OutputSurface thread after quitSafely to avoid SIGABRT on stale pthread_t. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Video/AutoVideoCompression.kt | 2 +- .../Video/VideoCompressionProfile.kt | 32 +- .../VideoCompressor/compressor/Compressor.kt | 85 +++- .../VideoCompressor/utils/CompressorUtils.kt | 3 +- .../utils/LocationExtractor.kt | 395 ++++++++++++++++++ .../videoHelpers/LocationBox.kt | 47 +++ .../videoHelpers/MP4Builder.kt | 27 +- .../VideoCompressor/videoHelpers/Mp4Movie.kt | 4 +- .../videoHelpers/OutputSurface.kt | 12 + .../Video/VideoCompressorHelper.kt | 25 +- ios/Video/VideoMain.swift | 30 +- 11 files changed, 596 insertions(+), 66 deletions(-) create mode 100644 android/src/main/java/com/reactnativecompressor/Video/VideoCompressor/utils/LocationExtractor.kt create mode 100644 android/src/main/java/com/reactnativecompressor/Video/VideoCompressor/videoHelpers/LocationBox.kt diff --git a/android/src/main/java/com/reactnativecompressor/Video/AutoVideoCompression.kt b/android/src/main/java/com/reactnativecompressor/Video/AutoVideoCompression.kt index d8ff8a13..489a08d6 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 8e21cf72..0fb84530 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 bf87530b..0de8c35c 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 @@ -18,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 @@ -99,7 +100,22 @@ 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) - val locationData = mediaMetadataRetriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_LOCATION) + // 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) + } + Log.i("Compressor", "source location resolved: $locationData (retriever=$retrievedLocation)") // Check if any metadata is missing if (rotationData.isNullOrEmpty() || bitrateData.isNullOrEmpty() || durationData.isNullOrEmpty()) { @@ -146,7 +162,7 @@ object Compressor { listener, duration, rotation, - locationData + locationData, ) } @@ -165,7 +181,7 @@ object Compressor { compressionProgressListener: CompressionProgressListener, duration: Long, rotation: Int, - location: String? = null + location: String?, ): Result { // Check if newWidth and newHeight are valid if (newWidth != 0 && newHeight != 0) { @@ -222,8 +238,20 @@ object Compressor { // 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 (outputFrameRate > 0) 1_000_000L / outputFrameRate else 0L + if (shouldDropFrames) 1_000_000L / outputFrameRate else 0L var nextTargetPtsUs: Long = 0L inputSurface = InputSurface(encoder.createInputSurface()) @@ -372,13 +400,21 @@ object Compressor { var doRender = bufferInfo.size != 0 && !isEos // Drop frames whose PTS falls before the next target slot. - // Only kicks in when targetFrameIntervalUs > 0 and the source - // is producing faster than the target frame rate. + // 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 = bufferInfo.presentationTimeUs + targetFrameIntervalUs + 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 + } } } @@ -672,7 +708,13 @@ object Compressor { 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. private fun dispose( videoIndex: Int, decoder: MediaCodec, @@ -681,15 +723,22 @@ object Compressor { 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 780d5032..4b8c56ab 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,7 +49,8 @@ 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, 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 00000000..3cfaf5f2 --- /dev/null +++ b/android/src/main/java/com/reactnativecompressor/Video/VideoCompressor/utils/LocationExtractor.kt @@ -0,0 +1,395 @@ +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.i( + TAG, + "LocationExtractor box scan: xyz=${state.xyz} itunes=${state.itunesLocation} loci=${state.loci} chosen=$viaBox" + ) + // 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 match=${match?.value}") + 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: $parsed") + } + } + + // 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: $parsed") + } + } + + // 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: $payload") + } + } + 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 00000000..a954da97 --- /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 28928952..52ace438 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 @@ -4,7 +4,6 @@ import android.media.MediaCodec import android.media.MediaFormat import org.mp4parser.Box import org.mp4parser.boxes.iso14496.part12.* -import org.mp4parser.support.AbstractBox import org.mp4parser.support.Matrix import java.io.FileOutputStream @@ -191,35 +190,21 @@ 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() - udta.addBox(XyzLocationBox(location)) + val xyz = LocationBox() + xyz.location = location + udta.addBox(xyz) movieBox.addBox(udta) } return movieBox } - // Apple-style "©xyz" geolocation atom inside udta. - // Payload: 2 bytes UTF-16 BE length, 2 bytes packed language ("eng" = 0x15C7), - // followed by ISO 6709 location string (e.g. "+12.3456-067.7890/"). - private class XyzLocationBox(private val location: String) : AbstractBox("©xyz") { - override fun getContentSize(): Long { - val bytes = location.toByteArray(Charsets.UTF_8) - return (4 + bytes.size).toLong() - } - - override fun _parseDetails(content: ByteBuffer?) {} - - override fun getContent(byteBuffer: ByteBuffer) { - val bytes = location.toByteArray(Charsets.UTF_8) - byteBuffer.putShort(bytes.size.toShort()) - byteBuffer.putShort(0x15C7.toShort()) - byteBuffer.put(bytes) - } - } - private fun createTrackBox(track: Track, movie: Mp4Movie): TrackBox { val trackBox = TrackBox() val tkhd = TrackHeaderBox() 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 7096d904..189955a8 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 @@ -19,8 +19,8 @@ class Mp4Movie { cacheFile = file } - fun setLocation(loc: String?) { - location = loc + fun setLocation(value: String?) { + location = value } fun getLocation(): String? = location 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 671d3d9d..74a930d1 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 @@ -54,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() @@ -63,6 +70,11 @@ class OutputSurface : OnFrameAvailableListener { mSurfaceTexture = null mCallbackThread.quitSafely() + try { + mCallbackThread.join(500) + } catch (ignored: InterruptedException) { + Thread.currentThread().interrupt() + } } /** diff --git a/android/src/main/java/com/reactnativecompressor/Video/VideoCompressorHelper.kt b/android/src/main/java/com/reactnativecompressor/Video/VideoCompressorHelper.kt index cfa56225..6669a27b 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 0aa36c76..3e29497d 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 { From 9ee526b5dd9d1066d9b17ab6de321876155f90e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=E1=BB=AFu=20Nguy=E1=BB=85n?= Date: Fri, 15 May 2026 18:43:42 +0700 Subject: [PATCH 04/13] fix: copyExifInfo --- .../Image/ImageCompressor.kt | 62 ++++++++++++------- 1 file changed, 41 insertions(+), 21 deletions(-) diff --git a/android/src/main/java/com/reactnativecompressor/Image/ImageCompressor.kt b/android/src/main/java/com/reactnativecompressor/Image/ImageCompressor.kt index b782fce2..f5a22412 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() From fdb2ac66d45a0a658aca8ba932988b84429fda5b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=E1=BB=AFu=20Nguy=E1=BB=85n?= Date: Sat, 6 Jun 2026 08:53:57 +0700 Subject: [PATCH 05/13] =?UTF-8?q?fix(video):=20address=20PR=20review=20?= =?UTF-8?q?=E2=80=94=20redact=20GPS=20logs,=20preflight=20DV,=20safer=20en?= =?UTF-8?q?coder/teardown?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - LocationExtractor/Compressor: log only location presence + source, never the ISO 6709 coordinate string (xyz/itunes/loci/SEF/resolved values) - Compressor: preflight Dolby Vision profile 5 before allocating muxer/encoder/EGL surfaces and drop the throw from prepareDecoder, so the unsupported case no longer leaks codec/GL resources on bail-out - Compressor: restore always-on streamable rewrite (moov atom to front) for default output to preserve progressive playback (revert behavior change) - CompressorUtils/Compressor: make VBR/priority/operating-rate throughput tuning optional and fall back to a default-rate-control configure when an encoder rejects the tuned format - Compressor: release partially-initialized encoder/decoder/EGL surfaces on any setup failure or in-loop throw (dispose tolerates null handles) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../VideoCompressor/compressor/Compressor.kt | 237 +++++++++++++----- .../VideoCompressor/utils/CompressorUtils.kt | 31 ++- .../utils/LocationExtractor.kt | 12 +- 3 files changed, 194 insertions(+), 86 deletions(-) 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 0de8c35c..5bb8d6d4 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 @@ -115,7 +115,14 @@ object Compressor { } else { LocationExtractor.extract(context, srcUri) } - Log.i("Compressor", "source location resolved: $locationData (retriever=$retrievedLocation)") + // 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()) { @@ -193,19 +200,32 @@ object Compressor { // input to generate a compressed/smaller size video val bufferInfo = MediaCodec.BufferInfo() - // Setup mp4 movie - val movie = setUpMP4Movie(rotation, cacheFile, location) - - // 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) + val outputFormat: MediaFormat = MediaFormat.createVideoFormat(MIME_TYPE, newWidth, newHeight) @@ -217,16 +237,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 @@ -254,14 +289,17 @@ object Compressor { if (shouldDropFrames) 1_000_000L / outputFrameRate else 0L var nextTargetPtsUs: Long = 0L - inputSurface = InputSurface(encoder.createInputSurface()) + 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() @@ -455,16 +493,29 @@ 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 + ) return Result(id, success = false, failureMessage = exception.message) } // Release resources dispose( videoIndex, - decoder, + decoderRef, encoder, - inputSurface, - outputSurface, + inputSurfaceRef, + outputSurfaceRef, extractor ) @@ -491,31 +542,29 @@ object Compressor { var resultFile = cacheFile - // StreamableVideo rewrites the whole MP4 to move the moov atom to the front, - // which doubles disk I/O. Only run it when the caller explicitly requested a - // streamable copy (non-null streamableFile). Chat uploads do not need it. - if (streamableFile != null) { - try { - val targetFile = File(streamableFile) - val outputFile = if (targetFile.absolutePath == cacheFile.absolutePath) { - getStreamableOutputFile(cacheFile) + try { + // 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) + } else { + targetFile + } + val result = StreamableVideo.start(`in` = cacheFile, out = outputFile) + if (result) { + if (streamableFile == null || targetFile.absolutePath == cacheFile.absolutePath) { + cacheFile.delete() + outputFile.renameTo(cacheFile) + resultFile = cacheFile } else { - targetFile + resultFile = outputFile + cacheFile.delete() } - val result = StreamableVideo.start(`in` = cacheFile, out = outputFile) - if (result) { - if (targetFile.absolutePath == cacheFile.absolutePath) { - cacheFile.delete() - outputFile.renameTo(cacheFile) - resultFile = cacheFile - } else { - resultFile = outputFile - cacheFile.delete() - } - } - } catch (e: Exception) { - printException(e) } + } catch (e: Exception) { + printException(e) } if (!resultFile.exists() || resultFile.length() <= 32) { return Result( @@ -614,18 +663,50 @@ object Compressor { } // Function to prepare the video encoder - private fun prepareEncoder(outputFormat: MediaFormat, hasQTI: Boolean): MediaCodec { + 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) - encoder.configure( - outputFormat, null, null, - MediaCodec.CONFIGURE_FLAG_ENCODE - ) - - Log.i("Compressor", "encoder selected: ${encoder.name}") + 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() } + } - return encoder + 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 { @@ -672,6 +753,21 @@ object Compressor { } } + // 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 private fun prepareDecoder( inputFormat: MediaFormat, @@ -681,18 +777,10 @@ object Compressor { // 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. Profile 5 has no compatible base - // layer and must be rejected upstream. + // 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)) { - val profile = if (inputFormat.containsKey(MediaFormat.KEY_PROFILE)) { - inputFormat.getInteger(MediaFormat.KEY_PROFILE) - } else { - -1 - } - // DV profile 5 = 0x20, no HEVC fallback. Profiles 8.x carry HEVC base layer. - if (profile == 0x20) { - throw IllegalStateException("Dolby Vision profile 5 has no HEVC base layer; cannot transcode") - } inputFormat.setString(MediaFormat.KEY_MIME, MediaFormat.MIMETYPE_VIDEO_HEVC) MediaFormat.MIMETYPE_VIDEO_HEVC } else { @@ -701,7 +789,14 @@ object Compressor { val decoder = MediaCodec.createDecoderByType(resolvedMime) - decoder.configure(inputFormat, outputSurface.getSurface(), null, 0) + 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 + } Log.i("Compressor", "decoder selected: ${decoder.name} mime=$resolvedMime") @@ -715,20 +810,24 @@ object Compressor { // 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 ) { runCatching { extractor.unselectTrack(videoIndex) } .onFailure { Log.w("Compressor", "extractor.unselectTrack failed", it) } - runCatching { decoder.stop() } + runCatching { decoder?.stop() } .onFailure { Log.w("Compressor", "decoder.stop failed", it) } - runCatching { decoder.release() } + runCatching { decoder?.release() } .onFailure { Log.w("Compressor", "decoder.release failed", it) } runCatching { encoder.stop() } @@ -736,9 +835,9 @@ object Compressor { runCatching { encoder.release() } .onFailure { Log.w("Compressor", "encoder.release failed", it) } - runCatching { inputSurface.release() } + runCatching { inputSurface?.release() } .onFailure { Log.w("Compressor", "inputSurface.release failed", it) } - runCatching { outputSurface.release() } + 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 4b8c56ab..801d4a62 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 @@ -74,6 +74,7 @@ object CompressorUtils { outputFormat: MediaFormat, newBitrate: Int, targetFrameRate: Int, + applyThroughputTuning: Boolean = true, ) { val newFrameRate = targetFrameRate.coerceAtLeast(1) val iFrameInterval = getIFrameIntervalRate(inputFormat) @@ -87,18 +88,24 @@ object CompressorUtils { setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, iFrameInterval) // Bitrate in bits per second setInteger(MediaFormat.KEY_BIT_RATE, newBitrate) - // 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 - // are required to unlock full throughput on Qualcomm / Exynos / MTK SoCs. - 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()) + + // 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 { 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 index 3cfaf5f2..1049c248 100644 --- a/android/src/main/java/com/reactnativecompressor/Video/VideoCompressor/utils/LocationExtractor.kt +++ b/android/src/main/java/com/reactnativecompressor/Video/VideoCompressor/utils/LocationExtractor.kt @@ -43,9 +43,11 @@ object LocationExtractor { 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: xyz=${state.xyz} itunes=${state.itunesLocation} loci=${state.loci} chosen=$viaBox" + "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, @@ -111,7 +113,7 @@ object LocationExtractor { buf.get(bytes) val text = String(bytes, StandardCharsets.ISO_8859_1) val match = ISO6709_REGEX.find(text) - Log.i(TAG, "LocationExtractor SEF trailer scan match=${match?.value}") + Log.i(TAG, "LocationExtractor SEF trailer scan matched=${match != null}") return match?.value } @@ -183,7 +185,7 @@ object LocationExtractor { val parsed = readXyz(channel, childStart, childEnd) if (!parsed.isNullOrEmpty() && state.xyz == null) { state.xyz = parsed - Log.i(TAG, "found ©xyz: $parsed") + Log.i(TAG, "found ©xyz") } } @@ -192,7 +194,7 @@ object LocationExtractor { val parsed = readLoci(channel, childStart, childEnd) if (!parsed.isNullOrEmpty() && state.loci == null) { state.loci = parsed - Log.i(TAG, "found loci: $parsed") + Log.i(TAG, "found loci") } } @@ -352,7 +354,7 @@ object LocationExtractor { val payload = findItunesData(channel, pos + 8, itemEnd) if (!payload.isNullOrEmpty() && state.itunesLocation == null) { state.itunesLocation = payload - Log.i(TAG, "found itunes location: $payload") + Log.i(TAG, "found itunes location") } } pos = itemEnd From c03e19110f94a2843185bb5164bfdbc532066732 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=E1=BB=AFu=20Nguy=E1=BB=85n?= Date: Sun, 7 Jun 2026 08:47:41 +0700 Subject: [PATCH 06/13] fix(android): close MP4Builder streams on compression failure paths MP4Builder opened its FileOutputStream/FileChannel in createMovie() but only closed them in finishMovie(). Any failure between muxer creation and a successful finishMovie() leaked the output file handle. - Add idempotent MP4Builder.close() to release streams without finalizing - createMovie() now closes its own streams if header writing throws - Compressor closes the muxer in the setup/in-loop catch, the finishMovie catch, and the outer catch (processAudio/extractor.release failures) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../VideoCompressor/compressor/Compressor.kt | 15 ++++++++ .../videoHelpers/MP4Builder.kt | 34 +++++++++++++++---- 2 files changed, 42 insertions(+), 7 deletions(-) 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 5bb8d6d4..ecd5fa3c 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 @@ -195,6 +195,10 @@ object Compressor { // 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 @@ -225,6 +229,7 @@ object Compressor { // MediaMuxer outputs MP4 in this app val mediaMuxer = MP4Builder().createMovie(movie) + muxer = mediaMuxer val outputFormat: MediaFormat = MediaFormat.createVideoFormat(MIME_TYPE, newWidth, newHeight) @@ -506,6 +511,9 @@ object Compressor { 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) } @@ -532,11 +540,18 @@ 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) } 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 52ace438..5db41eae 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( From 078914143c77458bbc6d0575178d43448541984b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=E1=BB=AFu=20Nguy=E1=BB=85n?= Date: Mon, 8 Jun 2026 19:33:59 +0700 Subject: [PATCH 07/13] chore: add CLAUDE.md --- .claude/rules/karpathy-guidelines.md | 68 +++++++++++++++++++++ CLAUDE.md | 89 ++++++++++++++++++++++++++++ 2 files changed, 157 insertions(+) create mode 100644 .claude/rules/karpathy-guidelines.md create mode 100644 CLAUDE.md diff --git a/.claude/rules/karpathy-guidelines.md b/.claude/rules/karpathy-guidelines.md new file mode 100644 index 00000000..f456e1f2 --- /dev/null +++ b/.claude/rules/karpathy-guidelines.md @@ -0,0 +1,68 @@ +# CLAUDE.md + +Behavioral guidelines to reduce common LLM coding mistakes. Merge with project-specific instructions as needed. + +**Tradeoff:** These guidelines bias toward caution over speed. For trivial tasks, use judgment. + +## 1. Think Before Coding + +**Don't assume. Don't hide confusion. Surface tradeoffs.** + +Before implementing: +- State your assumptions explicitly. If uncertain, ask. +- If multiple interpretations exist, present them - don't pick silently. +- If a simpler approach exists, say so. Push back when warranted. +- If something is unclear, stop. Name what's confusing. Ask. +- **Blast radius.** List what depends on this — callers, consumers, migrations, contracts, flags, cached state. What you can't enumerate, you can't reason about. +- **Guardrails inventory.** Know what already exists: validation, auth, rate limits, error handling. Don't duplicate. Don't silently bypass. If one needs to change, change it explicitly. + +## 2. Simplicity First + +**Minimum code that solves the problem. Nothing speculative.** + +- Write the code carefully. All code will be reviewed by Codex. +- No features beyond what was asked. +- No abstractions for single-use code. +- No "flexibility" or "configurability" that wasn't requested. +- No error handling for impossible scenarios. +- If you write 200 lines and it could be 50, rewrite it. + +Ask yourself: "Would a senior engineer say this is overcomplicated?" If yes, simplify. + +## 3. Surgical Changes + +**Touch only what you must. Clean up only your own mess.** + +When editing existing code: +- Don't "improve" adjacent code, comments, or formatting. +- Don't refactor things that aren't broken. +- Match existing style, even if you'd do it differently. +- If you notice unrelated dead code, mention it - don't delete it. + +When your changes create orphans: +- Remove imports/variables/functions that YOUR changes made unused. +- Don't remove pre-existing dead code unless asked. + +The test: Every changed line should trace directly to the user's request. + +## 4. Goal-Driven Execution + +**Define success criteria. Loop until verified.** + +Transform tasks into verifiable goals: +- "Add validation" → "Write tests for invalid inputs, then make them pass" +- "Fix the bug" → "Write a test that reproduces it, then make it pass" +- "Refactor X" → "Ensure tests pass before and after" + +For multi-step tasks, state a brief plan: +``` +1. [Step] → verify: [check] +2. [Step] → verify: [check] +3. [Step] → verify: [check] +``` + +Strong success criteria let you loop independently. Weak criteria ("make it work") require constant clarification. + +--- + +**These guidelines are working if:** fewer unnecessary changes in diffs, fewer rewrites due to overcomplication, and clarifying questions come before implementation rather than after mistakes. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..358f57e6 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,89 @@ +# CLAUDE.md — react-native-compressor + +## Project Overview + +`react-native-compressor` is a React Native library that compresses Image, Video, and Audio (WhatsApp-style auto compression, plus manual mode), with background upload/download and video thumbnail helpers. The TypeScript/JS layer is a thin wrapper over a single native module named `Compressor` implemented natively on Android (Kotlin) and iOS (Swift). It is published to npm as a library — the `examples/` apps exist only to develop and test against it. + +## Tech Stack & Architecture + +**JS layer:** TypeScript · single native module `Compressor` resolved in `src/Main.tsx` (TurboModule on New Arch, `NativeModules` fallback on old arch) +**Android native:** Kotlin · hand-rolled MediaCodec/MediaMuxer video transcoder +**iOS native:** Swift · AVFoundation via vendored `NextLevelSessionExporter.swift` +**Tooling:** Yarn 4 (Berry) workspace (`examples/*`) · Node `>= 22.11` · Jest (native mocked) · react-native-builder-bob · Expo config plugin + +### JS wrapper → single native module + +All native functionality is exposed through one module called `Compressor`. `src/Main.tsx` resolves it once: it uses the TurboModule spec (`src/Spec/NativeCompressor.ts`) when `global.__turboModuleProxy` exists (New Architecture), otherwise falls back to `NativeModules.Compressor` (old arch), and wraps a `Proxy` that throws a linking error if the module is missing. + +The public API is assembled in `src/index.tsx` from four domain modules plus utils: + +- `src/Image/index.tsx` — `Image.compress` (strips base64 data-URI headers before calling native) +- `src/Video/index.tsx` — `Video.compress`, `cancelCompression`, `activate/deactivateBackgroundTask` +- `src/Audio/index.tsx` — `Audio.compress` +- `src/utils/` — `Uploader.tsx` (`backgroundUpload`, `cancelUpload`), `Downloader.tsx` (`download`), `helpers.ts`, and metadata/path helpers (`getRealPath`, `getVideoMetaData`, `getImageMetaData`, `generateFilePath`, `createVideoThumbnail`, `clearCache`, `getFileSize`) + +### Progress is delivered via events, not the Promise + +Each `compress`/`upload`/`download` call generates a `uuid` (`uuidv4`) in JS and passes it to native. Native emits events on a `NativeEventEmitter` (`videoCompressProgress`, `downloadProgress`, `uploadProgress`, `backgroundTaskExpired`); the JS wrapper subscribes, **filters events by matching `event.uuid`**, forwards `event.data.progress` to the user callback, and removes the subscription in a `finally` block when the Promise settles. Cancellation (`cancelCompression`, `cancelUpload`) and `AbortController` signals also key off this uuid. When editing progress/cancellation logic, keep the uuid threading consistent across JS and both native sides. + +### Native code organization (mirrors the JS domains) + +- **Android** `android/src/main/java/com/reactnativecompressor/` → `Image/`, `Video/`, `Audio/`, `Utils/`. The video transcoder is hand-rolled under `Video/VideoCompressor/` (MediaCodec/MediaMuxer pipeline: `Compressor.kt`, `MP4Builder.kt`, surfaces/renderer, `utils/`). `VideoMain.compress` routes to auto vs manual via `VideoCompressorHelper`. `StreamableVideo.kt` moves the `moov` atom to the front of the output by default — preserve this behavior. +- **iOS** `ios/` → `Image/`, `Audio/` (with `FormatConverter/`), `Video/`, `Utils/`. Video uses `NextLevelSessionExporter.swift` (a vendored AVFoundation exporter) driven by `VideoMain.swift`. Event emission goes through `EventEmitterHandler.swift`. + +### Expo support + +`src/expo-plugin/compressor.ts` is an Expo config plugin; `app.plugin.js` loads its built output from `lib/commonjs/expo-plugin/compressor` (so the plugin only works after `yarn prepack`). + +--- + +## Commands + +Uses **Yarn 4** (Berry) and a Yarn workspace (`examples/*`). Node `>= 22.11`. + +```sh +yarn # install deps for root + example workspaces +yarn test # Jest unit tests (native is mocked — fast, no device) +yarn test path/to/file # run a single test file +yarn test -t "pattern" # run tests matching a name pattern +yarn typecheck # tsc --noEmit +yarn lint # eslint over **/*.{js,ts,tsx} +yarn lint --fix # auto-fix lint/prettier +yarn test:pr # full PR gate: test --runInBand + typecheck + lint +yarn prepack # build the publishable lib/ via react-native-builder-bob +yarn clean # delete android/ios build dirs (run before switching archs) +``` + +Example apps (workspace shortcuts): + +```sh +yarn example:bare start # Metro for the bare RN example +yarn example:bare android # run bare example on Android +yarn example:bare ios # run bare example on iOS +yarn example:expo start # Expo example +``` + +### On-device integration tests (react-native-harness) + +The Jest unit tests mock the native module, so **real media decoding is only exercised by the harness tests**, which run inside the bare example app on a booted simulator/emulator: + +```sh +yarn test:harness:android # requires an Android emulator (default Pixel_8_API_35) +yarn test:harness:ios # requires an iOS simulator (default iPhone 17 Pro, iOS 26.4) +``` + +Device/version overrides live in `examples/bare/rn-harness.config.mjs` (env vars `RN_HARNESS_ANDROID_DEVICE`, `RN_HARNESS_IOS_DEVICE`, `RN_HARNESS_IOS_VERSION`, etc.). The harness spec is `harness/native-compressor.harness.ts` (the copy under `examples/bare/harness/` re-exports it). + +--- + +## Gotchas +- **Spec in FOUR places — keep in sync.** Adding/renaming/changing a native method must touch all of: (1) `src/Spec/NativeCompressor.ts` (codegen TurboModule `Spec`, New Arch source of truth, library `RNCompressorSpec`); (2) `android/src/oldarch/CompressorSpec.kt` (abstract, old arch) + `android/src/newarch/CompressorSpec.kt` (extends codegen `NativeCompressorSpec`) — selected at build by `newArchEnabled` in `android/build.gradle`; (3) `android/src/main/java/com/reactnativecompressor/CompressorModule.kt` (impl, delegates to per-domain `*Main`; registered via `CompressorPackage.kt`/`TurboReactPackage`); (4) iOS `ios/Compressor.mm` (`RCT_EXTERN_METHOD` + TurboModule binding under `RCT_NEW_ARCH_ENABLED`) + `ios/CompressorManager.swift` (`@objc(Compressor) RCTEventEmitter`). +- **Streamable:** `StreamableVideo.kt` moves `moov` atom to front by default — preserve. +- **uuid threading:** keep `uuid` consistent across JS + both native sides for progress/cancellation. +- **Commits follow Conventional Commits** (`fix:`, `feat:`, `refactor:`, `docs:`, `test:`, `chore:`). `commit-msg` hook runs commitlint; `pre-commit` hook (lefthook) runs eslint + `tsc --noEmit` on staged files. Don't bypass. +- **Build output:** `lib/` and example workspaces excluded from lint/tsc/jest — don't edit `lib/` by hand. +- **Releases:** cut with `yarn release` (release-it + conventional-changelog). + +## Coding Guidelines + +@.claude/rules/karpathy-guidelines.md From 7eb518e849927db016817357f948d06776b8462a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=E1=BB=AFu=20Nguy=E1=BB=85n?= Date: Tue, 9 Jun 2026 09:37:14 +0700 Subject: [PATCH 08/13] feat: migrate Compressor native module to Nitro Modules Replace the legacy TurboModule/RCTEventEmitter bridge with a single Nitro HybridObject ("Compressor") generated by nitrogen, removing the four-place spec duplication in favor of one `.nitro.ts` source of truth. * JS: drop `src/Spec/NativeCompressor.ts`; add `src/specs/Compressor.nitro.ts` * Android: remove `CompressorModule`/`CompressorPackage`; add `HybridCompressor` * iOS: remove `Compressor.mm`/`.h`, `CompressorManager.swift`; add `HybridCompressor.swift` * Build: add `nitro.json` + nitrogen-generated sources, wire nitrogen into `prepack` BREAKING CHANGE: `react-native-compressor` now requires the New Architecture and `react-native-nitro-modules` (>=0.35.0) as a peer dependency. --- .nvmrc | 2 +- __tests__/compressor.test.ts | 114 ++-- android/CMakeLists.txt | 27 + android/build.gradle | 114 ++-- android/gradle.properties | 10 +- android/src/main/AndroidManifestNew.xml | 3 + android/src/main/cpp/cpp-adapter.cpp | 8 + .../nitro/compressor/HybridCompressor.kt | 213 ++++++++ .../reactnativecompressor/CompressorModule.kt | 180 ------- .../CompressorPackage.kt | 34 -- .../NitroCompressorPackage.kt | 30 ++ .../NitroPromiseAdapter.kt | 55 ++ .../Utils/EventEmitterHandler.kt | 94 ++-- .../com/reactnativecompressor/Utils/Utils.kt | 2 +- .../Video/VideoCompressorHelper.kt | 5 +- android/src/newarch/CompressorSpec.kt | 5 - android/src/oldarch/CompressorSpec.kt | 36 -- examples/bare/ios/Podfile.lock | 38 +- examples/bare/package.json | 5 +- examples/bare/rn-harness.config.mjs | 2 +- examples/expo/package.json | 1 + harness/native-compressor.harness.ts | 8 +- ios/Audio/AudioMain.swift | 1 + .../FormatConverter+Compressed.swift | 2 +- .../FormatConverter+Utilities.swift | 2 +- .../FormatConverter/FormatConverter.swift | 8 +- ios/Compressor-Bridging-Header.h | 6 - ios/Compressor.h | 5 - ios/Compressor.mm | 82 --- ios/CompressorManager.swift | 117 ----- ios/HybridCompressor.swift | 240 +++++++++ ios/Image/ImageMain.swift | 1 + ios/Utils/CreateVideoThumbnail.swift | 1 + ios/Utils/EventEmitterHandler.swift | 90 ++-- ios/Utils/Uploader.swift | 1 + ios/Utils/Utils.swift | 1 + ios/Video/VideoMain.swift | 2 + nitro.json | 24 + nitrogen/generated/.gitattributes | 1 + .../android/NitroCompressor+autolinking.cmake | 81 +++ .../NitroCompressor+autolinking.gradle | 27 + .../android/NitroCompressorOnLoad.cpp | 60 +++ .../android/NitroCompressorOnLoad.hpp | 34 ++ nitrogen/generated/android/c++/JFunc_void.hpp | 75 +++ .../android/c++/JFunc_void_double.hpp | 75 +++ .../android/c++/JFunc_void_double_double.hpp | 75 +++ .../android/c++/JHybridCompressorSpec.cpp | 293 +++++++++++ .../android/c++/JHybridCompressorSpec.hpp | 78 +++ .../android/c++/JVideoThumbnailResult.hpp | 73 +++ .../com/margelo/nitro/compressor/Func_void.kt | 80 +++ .../nitro/compressor/Func_void_double.kt | 80 +++ .../compressor/Func_void_double_double.kt | 80 +++ .../nitro/compressor/HybridCompressorSpec.kt | 141 +++++ .../nitro/compressor/NitroCompressorOnLoad.kt | 35 ++ .../nitro/compressor/VideoThumbnailResult.kt | 71 +++ .../ios/c++/HybridCompressorSpecSwift.cpp | 11 + .../ios/c++/HybridCompressorSpecSwift.hpp | 204 ++++++++ .../react_native_compressor+autolinking.rb | 62 +++ ...act_native_compressor-Swift-Cxx-Bridge.cpp | 89 ++++ ...act_native_compressor-Swift-Cxx-Bridge.hpp | 337 ++++++++++++ ...t_native_compressor-Swift-Cxx-Umbrella.hpp | 51 ++ .../ios/react_native_compressorAutolinking.mm | 33 ++ .../react_native_compressorAutolinking.swift | 26 + nitrogen/generated/ios/swift/Func_void.swift | 46 ++ .../Func_void_VideoThumbnailResult.swift | 46 ++ .../ios/swift/Func_void_double.swift | 46 ++ .../ios/swift/Func_void_double_double.swift | 46 ++ .../swift/Func_void_std__exception_ptr.swift | 46 ++ .../Func_void_std__shared_ptr_AnyMap_.swift | 46 ++ .../ios/swift/Func_void_std__string.swift | 46 ++ .../ios/swift/HybridCompressorSpec.swift | 70 +++ .../ios/swift/HybridCompressorSpec_cxx.swift | 493 ++++++++++++++++++ .../ios/swift/VideoThumbnailResult.swift | 49 ++ .../shared/c++/HybridCompressorSpec.cpp | 36 ++ .../shared/c++/HybridCompressorSpec.hpp | 83 +++ .../shared/c++/VideoThumbnailResult.hpp | 99 ++++ package.json | 18 +- react-native-compressor.podspec | 30 +- src/Audio/index.tsx | 4 +- src/Image/index.tsx | 29 +- src/Main.tsx | 30 +- src/Spec/NativeCompressor.ts | 41 -- src/Video/index.tsx | 102 +--- src/specs/Compressor.nitro.ts | 56 ++ src/utils/Downloader.tsx | 35 +- src/utils/Uploader.tsx | 36 +- src/utils/helpers.ts | 18 + src/utils/index.tsx | 5 +- yarn.lock | 86 ++- 89 files changed, 4428 insertions(+), 955 deletions(-) create mode 100644 android/CMakeLists.txt create mode 100644 android/src/main/cpp/cpp-adapter.cpp create mode 100644 android/src/main/java/com/margelo/nitro/compressor/HybridCompressor.kt delete mode 100644 android/src/main/java/com/reactnativecompressor/CompressorModule.kt delete mode 100644 android/src/main/java/com/reactnativecompressor/CompressorPackage.kt create mode 100644 android/src/main/java/com/reactnativecompressor/NitroCompressorPackage.kt create mode 100644 android/src/main/java/com/reactnativecompressor/NitroPromiseAdapter.kt delete mode 100644 android/src/newarch/CompressorSpec.kt delete mode 100644 android/src/oldarch/CompressorSpec.kt delete mode 100644 ios/Compressor-Bridging-Header.h delete mode 100644 ios/Compressor.h delete mode 100644 ios/Compressor.mm delete mode 100644 ios/CompressorManager.swift create mode 100644 ios/HybridCompressor.swift create mode 100644 nitro.json create mode 100644 nitrogen/generated/.gitattributes create mode 100644 nitrogen/generated/android/NitroCompressor+autolinking.cmake create mode 100644 nitrogen/generated/android/NitroCompressor+autolinking.gradle create mode 100644 nitrogen/generated/android/NitroCompressorOnLoad.cpp create mode 100644 nitrogen/generated/android/NitroCompressorOnLoad.hpp create mode 100644 nitrogen/generated/android/c++/JFunc_void.hpp create mode 100644 nitrogen/generated/android/c++/JFunc_void_double.hpp create mode 100644 nitrogen/generated/android/c++/JFunc_void_double_double.hpp create mode 100644 nitrogen/generated/android/c++/JHybridCompressorSpec.cpp create mode 100644 nitrogen/generated/android/c++/JHybridCompressorSpec.hpp create mode 100644 nitrogen/generated/android/c++/JVideoThumbnailResult.hpp create mode 100644 nitrogen/generated/android/kotlin/com/margelo/nitro/compressor/Func_void.kt create mode 100644 nitrogen/generated/android/kotlin/com/margelo/nitro/compressor/Func_void_double.kt create mode 100644 nitrogen/generated/android/kotlin/com/margelo/nitro/compressor/Func_void_double_double.kt create mode 100644 nitrogen/generated/android/kotlin/com/margelo/nitro/compressor/HybridCompressorSpec.kt create mode 100644 nitrogen/generated/android/kotlin/com/margelo/nitro/compressor/NitroCompressorOnLoad.kt create mode 100644 nitrogen/generated/android/kotlin/com/margelo/nitro/compressor/VideoThumbnailResult.kt create mode 100644 nitrogen/generated/ios/c++/HybridCompressorSpecSwift.cpp create mode 100644 nitrogen/generated/ios/c++/HybridCompressorSpecSwift.hpp create mode 100644 nitrogen/generated/ios/react_native_compressor+autolinking.rb create mode 100644 nitrogen/generated/ios/react_native_compressor-Swift-Cxx-Bridge.cpp create mode 100644 nitrogen/generated/ios/react_native_compressor-Swift-Cxx-Bridge.hpp create mode 100644 nitrogen/generated/ios/react_native_compressor-Swift-Cxx-Umbrella.hpp create mode 100644 nitrogen/generated/ios/react_native_compressorAutolinking.mm create mode 100644 nitrogen/generated/ios/react_native_compressorAutolinking.swift create mode 100644 nitrogen/generated/ios/swift/Func_void.swift create mode 100644 nitrogen/generated/ios/swift/Func_void_VideoThumbnailResult.swift create mode 100644 nitrogen/generated/ios/swift/Func_void_double.swift create mode 100644 nitrogen/generated/ios/swift/Func_void_double_double.swift create mode 100644 nitrogen/generated/ios/swift/Func_void_std__exception_ptr.swift create mode 100644 nitrogen/generated/ios/swift/Func_void_std__shared_ptr_AnyMap_.swift create mode 100644 nitrogen/generated/ios/swift/Func_void_std__string.swift create mode 100644 nitrogen/generated/ios/swift/HybridCompressorSpec.swift create mode 100644 nitrogen/generated/ios/swift/HybridCompressorSpec_cxx.swift create mode 100644 nitrogen/generated/ios/swift/VideoThumbnailResult.swift create mode 100644 nitrogen/generated/shared/c++/HybridCompressorSpec.cpp create mode 100644 nitrogen/generated/shared/c++/HybridCompressorSpec.hpp create mode 100644 nitrogen/generated/shared/c++/VideoThumbnailResult.hpp delete mode 100644 src/Spec/NativeCompressor.ts create mode 100644 src/specs/Compressor.nitro.ts diff --git a/.nvmrc b/.nvmrc index 3f430af8..db49bb14 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -v18 +22.22.2 diff --git a/__tests__/compressor.test.ts b/__tests__/compressor.test.ts index 0ec037a2..9d9a4ddf 100644 --- a/__tests__/compressor.test.ts +++ b/__tests__/compressor.test.ts @@ -1,6 +1,3 @@ -const mockListeners: Record void>> = {}; -const mockSubscriptions: Array<{ remove: jest.Mock }> = []; - // These unit tests validate the JavaScript wrapper contract. The native // Compressor module is mocked, so real media decoding must be smoke-tested // in an example app on a simulator or device. @@ -27,41 +24,26 @@ const localVideoUri = 'file:///tmp/react-native-compressor/input-video.mp4'; const localImageUri = 'file:///tmp/react-native-compressor/input-image.jpg'; const localAudioUri = 'file:///tmp/react-native-compressor/input-audio.wav'; -jest.mock('react-native', () => ({ - NativeModules: { - Compressor: mockCompressor, +// The wrapper resolves the native module through Nitro instead of NativeModules. +jest.mock('react-native-nitro-modules', () => ({ + NitroModules: { + createHybridObject: jest.fn(() => mockCompressor), }, - NativeEventEmitter: jest.fn().mockImplementation(() => ({ - addListener: jest.fn((eventName: string, callback: (event: unknown) => void) => { - const subscription = { remove: jest.fn() }; - mockListeners[eventName] = [...(mockListeners[eventName] ?? []), callback]; - mockSubscriptions.push(subscription); - return subscription; - }), - removeAllListeners: jest.fn((eventName: string) => { - mockListeners[eventName] = []; - }), - })), +})); + +jest.mock('react-native', () => ({ Platform: { OS: 'ios', select: jest.fn((options: Record) => options.ios ?? options.default), }, })); -const emitNativeEvent = (eventName: string, event: unknown) => { - (mockListeners[eventName] ?? []).forEach((callback) => callback(event)); -}; - describe('react-native-compressor JS wrapper API', () => { let api: typeof import('../src'); let reactNative: typeof import('react-native'); beforeEach(() => { jest.clearAllMocks(); - Object.keys(mockListeners).forEach((eventName) => { - mockListeners[eventName] = []; - }); - mockSubscriptions.length = 0; reactNative = require('react-native'); reactNative.Platform.OS = 'ios'; api = require('../src'); @@ -96,16 +78,9 @@ describe('react-native-compressor JS wrapper API', () => { expect(api.UploaderHttpMethod.PATCH).toBe('PATCH'); }); - it('compresses images, strips base64 headers, forwards progress, and removes listeners', async () => { - mockCompressor.image_compress.mockImplementation(async (_value, options) => { - emitNativeEvent('downloadProgress', { - uuid: options.uuid, - data: { progress: 55 }, - }); - emitNativeEvent('downloadProgress', { - uuid: 'other-id', - data: { progress: 99 }, - }); + it('compresses images, strips base64 headers, and forwards download progress', async () => { + mockCompressor.image_compress.mockImplementation(async (_value, _options, onDownloadProgress) => { + onDownloadProgress?.(55); return 'file://compressed-image.jpg'; }); const downloadProgress = jest.fn(); @@ -117,16 +92,9 @@ describe('react-native-compressor JS wrapper API', () => { }), ).resolves.toBe('file://compressed-image.jpg'); - expect(mockCompressor.image_compress).toHaveBeenCalledWith( - 'abc123', - expect.objectContaining({ - quality: 0.7, - uuid: expect.any(String), - }), - ); + expect(mockCompressor.image_compress).toHaveBeenCalledWith('abc123', expect.objectContaining({ quality: 0.7 }), expect.any(Function)); expect(downloadProgress).toHaveBeenCalledWith(55); expect(downloadProgress).toHaveBeenCalledTimes(1); - expect(mockSubscriptions.at(-1)?.remove).toHaveBeenCalledTimes(1); }); it('rejects empty image compression input before calling native code', async () => { @@ -134,16 +102,10 @@ describe('react-native-compressor JS wrapper API', () => { expect(mockCompressor.image_compress).not.toHaveBeenCalled(); }); - it('compresses videos with defaults, cancellation id, progress callbacks, and listener cleanup', async () => { - mockCompressor.compress.mockImplementation(async (_fileUrl, options) => { - emitNativeEvent('videoCompressProgress', { - uuid: options.uuid, - data: { progress: 22 }, - }); - emitNativeEvent('downloadProgress', { - uuid: options.uuid, - data: { progress: 33 }, - }); + it('compresses videos with defaults, cancellation id, and progress callbacks', async () => { + mockCompressor.compress.mockImplementation(async (_fileUrl, _options, onProgress, onDownloadProgress) => { + onProgress?.(22); + onDownloadProgress?.(33); return 'file://compressed-video.mp4'; }); const onProgress = jest.fn(); @@ -172,13 +134,12 @@ describe('react-native-compressor JS wrapper API', () => { maxSize: 640, stripAudio: true, }), + expect.any(Function), + expect.any(Function), ); expect(getCancellationId).toHaveBeenCalledWith(expect.any(String)); expect(onProgress).toHaveBeenCalledWith(22); expect(downloadProgress).toHaveBeenCalledWith(33); - mockSubscriptions.slice(-2).forEach((subscription) => { - expect(subscription.remove).toHaveBeenCalledTimes(1); - }); }); it('forwards manual video compression options and minimum file size', async () => { @@ -199,12 +160,14 @@ describe('react-native-compressor JS wrapper API', () => { minimumFileSizeForCompress: 10, progressDivider: 5, }), + undefined, + undefined, ); }); it('proxies video cancellation and background task lifecycle', async () => { - mockCompressor.activateBackgroundTask.mockImplementation(async () => { - emitNativeEvent('backgroundTaskExpired', { expired: true }); + mockCompressor.activateBackgroundTask.mockImplementation(async (_options, onExpired) => { + onExpired?.(); return 'activated'; }); mockCompressor.deactivateBackgroundTask.mockResolvedValue('deactivated'); @@ -215,9 +178,9 @@ describe('react-native-compressor JS wrapper API', () => { await expect(api.Video.deactivateBackgroundTask()).resolves.toBe('deactivated'); expect(mockCompressor.cancelCompression).toHaveBeenCalledWith('video-id'); - expect(mockCompressor.activateBackgroundTask).toHaveBeenCalledWith({}); + expect(mockCompressor.activateBackgroundTask).toHaveBeenCalledWith({}, expect.any(Function)); expect(mockCompressor.deactivateBackgroundTask).toHaveBeenCalledWith({}); - expect(onExpired).toHaveBeenCalledWith({ expired: true }); + expect(onExpired).toHaveBeenCalledWith(undefined); }); it('compresses audio with defaults and custom options', async () => { @@ -291,34 +254,31 @@ describe('react-native-compressor JS wrapper API', () => { }); }); - it('downloads files, strips Android file prefixes, reports progress, and removes listeners', async () => { + it('downloads files, strips Android file prefixes, and reports progress', async () => { reactNative.Platform.OS = 'android'; - mockCompressor.download.mockImplementation(async (_fileUrl, options) => { - emitNativeEvent('downloadProgress', { - uuid: options.uuid, - data: { progress: 88 }, - }); + mockCompressor.download.mockImplementation(async (_fileUrl, _options, onProgress) => { + onProgress?.(88); return '/downloads/file.mp4'; }); const downloadProgress = jest.fn(); await expect(api.download('file:///storage/input.mp4', downloadProgress, 10)).resolves.toBe('/downloads/file.mp4'); - expect(mockCompressor.download).toHaveBeenCalledWith('/storage/input.mp4', { - uuid: expect.any(String), - progressDivider: 10, - }); + expect(mockCompressor.download).toHaveBeenCalledWith( + '/storage/input.mp4', + { + uuid: expect.any(String), + progressDivider: 10, + }, + expect.any(Function), + ); expect(downloadProgress).toHaveBeenCalledWith(88); - expect(mockSubscriptions.at(-1)?.remove).toHaveBeenCalledTimes(1); }); it('uploads files with options, progress, cancellation id, abort handling, and Android path normalization', async () => { reactNative.Platform.OS = 'android'; - mockCompressor.upload.mockImplementation(async (_fileUrl, options) => { - emitNativeEvent('uploadProgress', { - uuid: options.uuid, - data: { written: 4, total: 10 }, - }); + mockCompressor.upload.mockImplementation(async (_fileUrl, _options, onProgress) => { + onProgress?.(4, 10); return { status: 200 }; }); const onProgress = jest.fn(); @@ -356,11 +316,11 @@ describe('react-native-compressor JS wrapper API', () => { headers: { Authorization: 'token' }, parameters: { album: 'demo' }, }), + expect.any(Function), ); expect(getCancellationId).toHaveBeenCalledWith(uploadOptions.uuid); expect(onProgress).toHaveBeenCalledWith(4, 10); expect(mockCompressor.cancelUpload).toHaveBeenCalledWith(uploadOptions.uuid, false); - expect(mockSubscriptions.at(-1)?.remove).toHaveBeenCalledTimes(1); }); it('cancels one upload or all uploads through the native module', () => { diff --git a/android/CMakeLists.txt b/android/CMakeLists.txt new file mode 100644 index 00000000..9f3019fa --- /dev/null +++ b/android/CMakeLists.txt @@ -0,0 +1,27 @@ +project(NitroCompressor) +cmake_minimum_required(VERSION 3.9.0) + +set(PACKAGE_NAME NitroCompressor) +set(CMAKE_VERBOSE_MAKEFILE ON) +set(CMAKE_CXX_STANDARD 20) + +# Define the C++ library and add all of our own sources. +add_library(${PACKAGE_NAME} SHARED + src/main/cpp/cpp-adapter.cpp +) + +# Add all files generated by Nitrogen (sources, headers, prefab links). +include(${CMAKE_SOURCE_DIR}/../nitrogen/generated/android/NitroCompressor+autolinking.cmake) + +# Local includes +include_directories( + "src/main/cpp" +) + +find_library(LOG_LIB log) + +target_link_libraries( + ${PACKAGE_NAME} + ${LOG_LIB} + android +) diff --git a/android/build.gradle b/android/build.gradle index bb2e287a..81cf0c64 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -1,5 +1,5 @@ buildscript { - // Buildscript is evaluated before everything else so we can't use getExtOrDefault + // Buildscript is evaluated before everything else so we can't use getExtOrDefault def kotlin_version = rootProject.ext.has("kotlinVersion") ? rootProject.ext.get("kotlinVersion") : project.properties["Compressor_kotlinVersion"] repositories { @@ -8,25 +8,21 @@ buildscript { } dependencies { - classpath "com.android.tools.build:gradle:7.2.1" + classpath "com.android.tools.build:gradle:9.2.1" // noinspection DifferentKotlinGradleVersion classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" } } -def isNewArchitectureEnabled() { - return rootProject.hasProperty("newArchEnabled") && rootProject.getProperty("newArchEnabled") == "true" +def reactNativeArchitectures() { + def value = rootProject.getProperties().get("reactNativeArchitectures") + return value ? value.split(",") : ["armeabi-v7a", "x86", "x86_64", "arm64-v8a"] } apply plugin: "com.android.library" apply plugin: "kotlin-android" - - -def appProject = rootProject.allprojects.find { it.plugins.hasPlugin('com.android.application') } - -if (isNewArchitectureEnabled()) { - apply plugin: "com.facebook.react" -} +// Nitrogen: registers the generated Kotlin sources with this Gradle project. +apply from: '../nitrogen/generated/android/NitroCompressor+autolinking.gradle' def getExtOrDefault(name) { return rootProject.ext.has(name) ? rootProject.ext.get(name) : project.properties["Compressor_" + name] @@ -36,40 +32,62 @@ def getExtOrIntegerDefault(name) { return rootProject.ext.has(name) ? rootProject.ext.get(name) : (project.properties["Compressor_" + name]).toInteger() } -def supportsNamespace() { - def parsed = com.android.Version.ANDROID_GRADLE_PLUGIN_VERSION.tokenize('.') - def major = parsed[0].toInteger() - def minor = parsed[1].toInteger() - - // Namespace support was added in 7.3.0 - if (major == 7 && minor >= 3) { - return true - } - - return major >= 8 -} - android { - if (supportsNamespace()) { - namespace "com.reactnativecompressor" + namespace "com.reactnativecompressor" - sourceSets { - main { - manifest.srcFile "src/main/AndroidManifestNew.xml" - } + ndkVersion getExtOrDefault("ndkVersion") + compileSdkVersion getExtOrIntegerDefault("compileSdkVersion") + + sourceSets { + main { + manifest.srcFile "src/main/AndroidManifestNew.xml" } } - compileSdkVersion getExtOrIntegerDefault("compileSdkVersion") - defaultConfig { minSdkVersion getExtOrIntegerDefault("minSdkVersion") targetSdkVersion getExtOrIntegerDefault("targetSdkVersion") - buildConfigField "boolean", "IS_NEW_ARCHITECTURE_ENABLED", isNewArchitectureEnabled().toString() + + externalNativeBuild { + cmake { + cppFlags "-frtti -fexceptions -Wall -Wextra -fstack-protector-all -std=c++20" + arguments "-DANDROID_STL=c++_shared", "-DANDROID_SUPPORT_FLEXIBLE_PAGE_SIZES=ON" + abiFilters(*reactNativeArchitectures()) + } + } } + + externalNativeBuild { + cmake { + path "CMakeLists.txt" + } + } + buildFeatures { - buildConfig true + prefab true } + + packagingOptions { + excludes = [ + "META-INF", + "META-INF/**", + "**/libc++_shared.so", + "**/libfbjni.so", + "**/libjsi.so", + "**/libfolly_json.so", + "**/libfolly_runtime.so", + "**/libglog.so", + "**/libhermes.so", + "**/libhermes-executor-debug.so", + "**/libhermes_executor.so", + "**/libreactnative.so", + "**/libreactnativejni.so", + "**/libturbomodulejsijni.so", + "**/libreact_nativemodule_core.so", + "**/libjscexecutor.so" + ] + } + buildTypes { release { minifyEnabled false @@ -81,22 +99,8 @@ android { } compileOptions { - sourceCompatibility JavaVersion.VERSION_11 - targetCompatibility JavaVersion.VERSION_11 - } - - sourceSets { - main { - if (isNewArchitectureEnabled()) { - java.srcDirs += [ - "src/newarch", - // This is needed to build Kotlin project with NewArch enabled - "${project.buildDir}/generated/source/codegen/java" - ] - } else { - java.srcDirs += ["src/oldarch"] - } - } + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 } } @@ -114,6 +118,8 @@ dependencies { //noinspection GradleDynamicVersion implementation "com.facebook.react:react-native:+" implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" + // Nitro core (provided as a Gradle subproject by autolinking). + implementation project(":react-native-nitro-modules") implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.4" implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.4" @@ -121,11 +127,3 @@ dependencies { implementation 'com.github.kaushik-naik:TAndroidLame:277c2ab4b0' implementation 'javazoom:jlayer:1.0.1' } - -if (isNewArchitectureEnabled()) { - react { - jsRootDir = file("../src/") - libraryName = "Compressor" - codegenJavaPackageName = "com.reactnativecompressor" - } -} diff --git a/android/gradle.properties b/android/gradle.properties index 7d25f089..02d59bc1 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -1,5 +1,5 @@ -Compressor_kotlinVersion=1.7.0 -Compressor_minSdkVersion=21 -Compressor_targetSdkVersion=31 -Compressor_compileSdkVersion=31 -Compressor_ndkversion=21.4.7075529 +Compressor_kotlinVersion=2.1.20 +Compressor_minSdkVersion=24 +Compressor_targetSdkVersion=36 +Compressor_compileSdkVersion=36 +Compressor_ndkVersion=27.1.12297006 diff --git a/android/src/main/AndroidManifestNew.xml b/android/src/main/AndroidManifestNew.xml index a2f47b60..5bd7ed88 100644 --- a/android/src/main/AndroidManifestNew.xml +++ b/android/src/main/AndroidManifestNew.xml @@ -1,2 +1,5 @@ + + diff --git a/android/src/main/cpp/cpp-adapter.cpp b/android/src/main/cpp/cpp-adapter.cpp new file mode 100644 index 00000000..e89cc8c7 --- /dev/null +++ b/android/src/main/cpp/cpp-adapter.cpp @@ -0,0 +1,8 @@ +#include +#include + +#include "NitroCompressorOnLoad.hpp" + +JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM* vm, void*) { + return facebook::jni::initialize(vm, [] { margelo::nitro::compressor::registerAllNatives(); }); +} diff --git a/android/src/main/java/com/margelo/nitro/compressor/HybridCompressor.kt b/android/src/main/java/com/margelo/nitro/compressor/HybridCompressor.kt new file mode 100644 index 00000000..22e430c5 --- /dev/null +++ b/android/src/main/java/com/margelo/nitro/compressor/HybridCompressor.kt @@ -0,0 +1,213 @@ +package com.margelo.nitro.compressor + +import com.facebook.react.bridge.Arguments +import com.facebook.react.bridge.ReadableMap +import com.facebook.react.bridge.WritableMap +import com.margelo.nitro.NitroModules +import com.margelo.nitro.core.AnyMap +import com.margelo.nitro.core.Promise +import com.reactnativecompressor.Audio.AudioMain +import com.reactnativecompressor.Image.ImageMain +import com.reactnativecompressor.NitroPromiseAdapter +import com.reactnativecompressor.Utils.CreateVideoThumbnailClass +import com.reactnativecompressor.Utils.Downloader +import com.reactnativecompressor.Utils.EventEmitterHandler +import com.reactnativecompressor.Utils.Uploader +import com.reactnativecompressor.Utils.Utils +import com.reactnativecompressor.Video.VideoMain +import java.util.UUID +import java.util.concurrent.Executors +import com.facebook.react.bridge.Promise as RNPromise + +/** + * Nitro HybridObject implementation of the single `Compressor` native module. + * + * Thin binding layer: it converts Nitro's `AnyMap` options into the + * `ReadableMap` the existing domain code already consumes, bridges the Nitro + * `Promise` to the domain layer's `com.facebook.react.bridge.Promise` via + * [NitroPromiseAdapter], and registers progress callbacks (keyed by `uuid`) + * with [EventEmitterHandler]. All heavy logic stays in the domain classes, + * which run on a background executor so the JS thread is never blocked. + */ +class HybridCompressor : HybridCompressorSpec() { + private val reactContext + get() = NitroModules.applicationContext + ?: throw IllegalStateException("react-native-compressor: ReactApplicationContext is not available") + + private val executor = Executors.newCachedThreadPool() + + private val imageMain by lazy { ImageMain(reactContext) } + private val videoMain by lazy { VideoMain(reactContext) } + private val audioMain by lazy { AudioMain(reactContext) } + private val uploader by lazy { Uploader(reactContext) } + private val videoThumbnail by lazy { CreateVideoThumbnailClass(reactContext) } + + // region Converters + + private val toStringResult: (Any?) -> String = { it as? String ?: "" } + + private val toAnyMapResult: (Any?) -> AnyMap = { value -> + val hashMap = (value as? ReadableMap)?.toHashMap() ?: HashMap() + AnyMap.fromMap(hashMap, true) + } + + private val toThumbnailResult: (Any?) -> VideoThumbnailResult = { value -> + val map = value as ReadableMap + VideoThumbnailResult( + if (map.hasKey("path")) map.getString("path") ?: "" else "", + if (map.hasKey("size")) map.getDouble("size") else 0.0, + if (map.hasKey("mime")) map.getString("mime") ?: "" else "", + if (map.hasKey("width")) map.getDouble("width") else 0.0, + if (map.hasKey("height")) map.getDouble("height") else 0.0, + ) + } + + // endregion + + // region Image + + override fun image_compress(imagePath: String, optionMap: AnyMap, onDownloadProgress: ((progress: Double) -> Unit)?): Promise { + val map = toWritableMap(optionMap) + // Remote-image download progress is keyed by `uuid` inside the Downloader. The JS + // layer no longer sends one (the callback is passed directly), so mint one here. + val uuid = if (map.hasKey("uuid")) map.getString("uuid") ?: UUID.randomUUID().toString() else UUID.randomUUID().toString() + map.putString("uuid", uuid) + EventEmitterHandler.registerDownloadProgress(uuid, onDownloadProgress) + return runOnExecutor(toStringResult, { EventEmitterHandler.unregister(uuid) }) { promise -> + imageMain.image_compress(imagePath, map, promise) + } + } + + override fun getImageMetaData(filePath: String): Promise { + return runOnExecutor(toAnyMapResult) { promise -> imageMain.getImageMetaData(filePath, promise) } + } + + // endregion + + // region Video + + override fun compress( + fileUrl: String, + optionMap: AnyMap, + onProgress: ((progress: Double) -> Unit)?, + onDownloadProgress: ((progress: Double) -> Unit)?, + ): Promise { + val map = toWritableMap(optionMap) + val uuid = if (map.hasKey("uuid")) map.getString("uuid") ?: "" else "" + EventEmitterHandler.registerVideoCompressProgress(uuid, onProgress) + EventEmitterHandler.registerDownloadProgress(uuid, onDownloadProgress) + return runOnExecutor(toStringResult, { EventEmitterHandler.unregister(uuid) }) { promise -> + videoMain.compress(fileUrl, map, promise) + } + } + + override fun cancelCompression(uuid: String) { + videoMain.cancelCompression(uuid) + } + + override fun getVideoMetaData(filePath: String): Promise { + return runOnExecutor(toAnyMapResult) { promise -> videoMain.getVideoMetaData(filePath, promise) } + } + + override fun activateBackgroundTask(options: AnyMap, onExpired: (() -> Unit)?): Promise { + EventEmitterHandler.setBackgroundTaskExpiredCallback(onExpired) + val map = toWritableMap(options) + return runOnExecutor(toStringResult) { promise -> videoMain.activateBackgroundTask(map, promise) } + } + + override fun deactivateBackgroundTask(options: AnyMap): Promise { + EventEmitterHandler.setBackgroundTaskExpiredCallback(null) + val map = toWritableMap(options) + return runOnExecutor(toStringResult) { promise -> videoMain.deactivateBackgroundTask(map, promise) } + } + + // endregion + + // region Audio + + override fun compress_audio(fileUrl: String, optionMap: AnyMap): Promise { + val map = toWritableMap(optionMap) + return runOnExecutor(toStringResult) { promise -> audioMain.compress_audio(fileUrl, map, promise) } + } + + // endregion + + // region Upload / Download + + override fun upload(fileUrl: String, options: AnyMap, onProgress: ((written: Double, total: Double) -> Unit)?): Promise { + val map = toWritableMap(options) + val uuid = if (map.hasKey("uuid")) map.getString("uuid") ?: "" else "" + EventEmitterHandler.registerUploadProgress(uuid, onProgress) + return runOnExecutor(toAnyMapResult, { EventEmitterHandler.unregister(uuid) }) { promise -> + uploader.upload(fileUrl, map, reactContext, promise) + } + } + + override fun cancelUpload(uuid: String, shouldCancelAll: Boolean) { + uploader.cancelUpload(uuid, shouldCancelAll) + } + + override fun download(fileUrl: String, options: AnyMap, onProgress: ((progress: Double) -> Unit)?): Promise { + val uuid = if (options.contains("uuid") && options.isString("uuid")) options.getString("uuid") else "" + val progressDivider = if (options.contains("progressDivider") && options.isDouble("progressDivider")) options.getDouble("progressDivider").toInt() else 0 + EventEmitterHandler.registerDownloadProgress(uuid, onProgress) + return runOnExecutor(toStringResult, { EventEmitterHandler.unregister(uuid) }) { promise -> + val downloadedFilePath = Downloader.downloadMediaWithProgress(fileUrl, uuid, progressDivider, reactContext) + if (downloadedFilePath != null) promise.resolve(downloadedFilePath) else promise.reject("Unable to download", "Unable to download") + } + } + + // endregion + + // region Others + + override fun generateFilePath(fileExtension: String): Promise { + return runOnExecutor(toStringResult) { promise -> promise.resolve(Utils.generateCacheFilePath(fileExtension, reactContext)) } + } + + override fun getRealPath(path: String, type: String): Promise { + // Utils.getRealPath already returns a `file://`-prefixed path via slashifyFilePath, + // so it must not be prefixed again (that produced a malformed `file://file:///…`). + return runOnExecutor(toStringResult) { promise -> promise.resolve(Utils.getRealPath(path, reactContext)) } + } + + override fun getFileSize(filePath: String): Promise { + return runOnExecutor(toStringResult) { promise -> Utils.getFileSize(filePath, promise, reactContext) } + } + + override fun createVideoThumbnail(fileUrl: String, options: AnyMap): Promise { + val map = toWritableMap(options) + return runOnExecutor(toThumbnailResult) { promise -> videoThumbnail.create(fileUrl, map, promise) } + } + + override fun clearCache(cacheDir: String?): Promise { + return runOnExecutor(toStringResult) { promise -> CreateVideoThumbnailClass.clearCache(cacheDir, promise, reactContext) } + } + + // endregion + + // region Helpers + + /** Convert a Nitro [AnyMap] to a React Native [WritableMap] that the domain parsers consume. */ + private fun toWritableMap(map: AnyMap): WritableMap = Arguments.makeNativeMap(map.toHashMap()) + + /** + * Runs [block] on a background thread, handing it a bridge [RNPromise] that + * resolves/rejects the returned Nitro [Promise]. Synchronous throws are routed + * to the same adapter so the Promise is never double-settled. + */ + private fun runOnExecutor(convert: (Any?) -> T, onSettle: () -> Unit = {}, block: (RNPromise) -> Unit): Promise { + val promise = Promise() + val adapter = NitroPromiseAdapter(promise, convert, onSettle) + executor.execute { + try { + block(adapter) + } catch (e: Throwable) { + adapter.reject(e) + } + } + return promise + } + + // endregion +} diff --git a/android/src/main/java/com/reactnativecompressor/CompressorModule.kt b/android/src/main/java/com/reactnativecompressor/CompressorModule.kt deleted file mode 100644 index f758458b..00000000 --- a/android/src/main/java/com/reactnativecompressor/CompressorModule.kt +++ /dev/null @@ -1,180 +0,0 @@ -package com.reactnativecompressor - -import android.os.Build -import androidx.annotation.RequiresApi -import com.facebook.react.bridge.Promise -import com.facebook.react.bridge.ReactApplicationContext -import com.facebook.react.bridge.ReactMethod -import com.facebook.react.bridge.ReadableMap -import com.reactnativecompressor.Audio.AudioMain -import com.reactnativecompressor.Image.ImageMain -import com.reactnativecompressor.Utils.CreateVideoThumbnailClass -import com.reactnativecompressor.Utils.Downloader -import com.reactnativecompressor.Utils.EventEmitterHandler -import com.reactnativecompressor.Utils.Uploader -import com.reactnativecompressor.Utils.Utils -import com.reactnativecompressor.Utils.Utils.generateCacheFilePath -import com.reactnativecompressor.Utils.Utils.getRealPath -import com.reactnativecompressor.Video.VideoMain - -class CompressorModule(private val reactContext: ReactApplicationContext) : CompressorSpec(reactContext) { - private val imageMain: ImageMain = ImageMain(reactContext) - private val videoMain: VideoMain = VideoMain(reactContext) - private val audioMain: AudioMain = AudioMain(reactContext) - private val uploader: Uploader = Uploader(reactContext) - private val videoThumbnail: CreateVideoThumbnailClass = CreateVideoThumbnailClass(reactContext) - - override fun initialize() { - super.initialize() - EventEmitterHandler.reactContext=reactContext; - } - - - companion object { - const val NAME = "Compressor" - } - override fun getName(): String { - return NAME - } - - //Image - @ReactMethod - override fun image_compress( - imagePath: String, - optionMap: ReadableMap, - promise: Promise) { - imageMain.image_compress(imagePath,optionMap,promise) - } - - @ReactMethod - override fun getImageMetaData(filePath: String, promise: Promise) { - imageMain.getImageMetaData(filePath,promise) - } - - // VIdeo - @ReactMethod - override fun compress( - fileUrl: String, - optionMap: ReadableMap, - promise: Promise) { - videoMain.compress(fileUrl,optionMap,promise) - } - - @ReactMethod - override fun cancelCompression( - uuid: String) { - videoMain.cancelCompression(uuid) - } - - @ReactMethod - override fun activateBackgroundTask( - options: ReadableMap, - promise: Promise) { - videoMain.activateBackgroundTask(options,promise) - } - - @ReactMethod - override fun deactivateBackgroundTask( - options: ReadableMap, - promise: Promise) { - videoMain.deactivateBackgroundTask(options,promise) - } - - // Audio - @ReactMethod - @RequiresApi(api = Build.VERSION_CODES.JELLY_BEAN_MR2) - override fun compress_audio( - fileUrl: String, - optionMap: ReadableMap, - promise: Promise) { - audioMain.compress_audio(fileUrl,optionMap,promise) - } - - // Others - @ReactMethod - override fun generateFilePath(_extension: String, promise: Promise) { - try { - val outputUri = generateCacheFilePath(_extension, reactContext) - promise.resolve(outputUri) - } catch (e: Exception) { - promise.reject(e) - } - } - - @ReactMethod - override fun getRealPath(path: String, type: String, promise: Promise) { - try { - val realPath = getRealPath(path, reactContext) - promise.resolve("file://$realPath") - } catch (e: Exception) { - promise.reject(e) - } - } - - @ReactMethod - override fun getVideoMetaData(filePath: String, promise: Promise) { - videoMain.getVideoMetaData(filePath,promise) - } - - @ReactMethod - override fun getFileSize(filePath: String, promise: Promise) { - Utils.getFileSize(filePath,promise,reactContext) - } - - @ReactMethod - override fun upload( - fileUrl: String, - options: ReadableMap, - promise: Promise) { - uploader.upload(fileUrl, options, reactContext, promise) - } - - @ReactMethod - override fun cancelUpload(uuid: String,shouldCancelAll:Boolean) { - uploader.cancelUpload(uuid,shouldCancelAll) - } - - @ReactMethod - override fun download( - fileUrl: String, - options: ReadableMap, - promise: Promise) { - var uuid: String = "" - var progressDivider=0; - if(options.hasKey("uuid")) - { - uuid= options.getString("uuid") as String - } - if(options.hasKey("progressDivider")) - { - progressDivider= options.getString("progressDivider") as Int - } - val downloadedFilePath:String?=Downloader.downloadMediaWithProgress(fileUrl, uuid,progressDivider,reactContext) - if(downloadedFilePath!=null) - { - promise.resolve(downloadedFilePath) - } - else - { - promise.reject("Unable to download") - } - } - - @ReactMethod - override fun createVideoThumbnail(fileUrl:String, options:ReadableMap, promise:Promise) { - videoThumbnail.create(fileUrl,options,promise) - } - - @ReactMethod - override fun clearCache(cacheDir:String?, promise:Promise) { - CreateVideoThumbnailClass.clearCache(cacheDir, promise, reactContext) - } - - @ReactMethod - override fun addListener(eventName: String) { - } - - @ReactMethod - override fun removeListeners(count: Double) { - } -} diff --git a/android/src/main/java/com/reactnativecompressor/CompressorPackage.kt b/android/src/main/java/com/reactnativecompressor/CompressorPackage.kt deleted file mode 100644 index 85f08ef8..00000000 --- a/android/src/main/java/com/reactnativecompressor/CompressorPackage.kt +++ /dev/null @@ -1,34 +0,0 @@ -package com.reactnativecompressor - -import com.facebook.react.TurboReactPackage -import com.facebook.react.bridge.NativeModule -import com.facebook.react.bridge.ReactApplicationContext -import com.facebook.react.module.model.ReactModuleInfo -import com.facebook.react.module.model.ReactModuleInfoProvider - -class CompressorPackage : TurboReactPackage() { - override fun getModule(name: String, reactContext: ReactApplicationContext): NativeModule? { - return if (name == CompressorModule.NAME) { - CompressorModule(reactContext) - } else { - null - } - } - - override fun getReactModuleInfoProvider(): ReactModuleInfoProvider { - return ReactModuleInfoProvider { - val moduleInfos: MutableMap = HashMap() - val isTurboModule = BuildConfig.IS_NEW_ARCHITECTURE_ENABLED - moduleInfos[CompressorModule.NAME] = ReactModuleInfo( - CompressorModule.NAME, - CompressorModule.NAME, - false, // canOverrideExistingModule - false, // needsEagerInit - true, // hasConstants - false, // isCxxModule - isTurboModule // isTurboModule - ) - moduleInfos - } - } -} diff --git a/android/src/main/java/com/reactnativecompressor/NitroCompressorPackage.kt b/android/src/main/java/com/reactnativecompressor/NitroCompressorPackage.kt new file mode 100644 index 00000000..07973fb1 --- /dev/null +++ b/android/src/main/java/com/reactnativecompressor/NitroCompressorPackage.kt @@ -0,0 +1,30 @@ +package com.reactnativecompressor + +import com.facebook.react.BaseReactPackage +import com.facebook.react.bridge.NativeModule +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.module.model.ReactModuleInfoProvider +import com.margelo.nitro.compressor.NitroCompressorOnLoad + +/** + * Empty React package whose sole jobs are: + * 1. Make this library discoverable by React Native autolinking (which keys off a + * `ReactPackage`), so the Gradle project + native `.so` get wired into the app. + * 2. Load the Nitro C++ library (`libNitroCompressor.so`) on first class-load so the + * `Compressor` HybridObject is registered. The module itself is served by Nitro, + * not by `getModule`, so no native modules are returned here. + * + * Lives in the `com.reactnativecompressor` namespace (not `com.margelo.nitro.compressor`) + * because the React Native CLI derives the autolink import path from the Android namespace. + */ +class NitroCompressorPackage : BaseReactPackage() { + override fun getModule(name: String, reactContext: ReactApplicationContext): NativeModule? = null + + override fun getReactModuleInfoProvider(): ReactModuleInfoProvider = ReactModuleInfoProvider { HashMap() } + + companion object { + init { + NitroCompressorOnLoad.initializeNative() + } + } +} diff --git a/android/src/main/java/com/reactnativecompressor/NitroPromiseAdapter.kt b/android/src/main/java/com/reactnativecompressor/NitroPromiseAdapter.kt new file mode 100644 index 00000000..a6c142a9 --- /dev/null +++ b/android/src/main/java/com/reactnativecompressor/NitroPromiseAdapter.kt @@ -0,0 +1,55 @@ +package com.reactnativecompressor + +import com.facebook.react.bridge.Promise +import com.facebook.react.bridge.WritableMap +import java.util.concurrent.atomic.AtomicBoolean +import com.margelo.nitro.core.Promise as NitroPromise + +/** + * Adapts the React Native bridge [Promise] to a Nitro [NitroPromise] so the existing + * domain methods — which speak the bridge `Promise` contract (`resolve`/`reject`) — can + * drive a Nitro Promise unchanged. + * + * [convert] maps the resolved bridge value to the Nitro result type [T]; + * [onSettle] runs once on resolve/reject (used to unregister progress callbacks). + */ +class NitroPromiseAdapter( + private val promise: NitroPromise, + private val convert: (Any?) -> T, + private val onSettle: () -> Unit = {}, +) : Promise { + private val settled = AtomicBoolean(false) + + override fun resolve(value: Any?) { + if (!settled.compareAndSet(false, true)) return + onSettle() + promise.resolve(convert(value)) + } + + private fun rejectInternal(message: String?, throwable: Throwable?) { + if (!settled.compareAndSet(false, true)) return + onSettle() + promise.reject(throwable ?: Throwable(message ?: "react-native-compressor error")) + } + + override fun reject(code: String?, message: String?) = rejectInternal(message ?: code, null) + + override fun reject(code: String?, throwable: Throwable?) = rejectInternal(code, throwable) + + override fun reject(code: String?, message: String?, throwable: Throwable?) = rejectInternal(message ?: code, throwable) + + override fun reject(throwable: Throwable) = rejectInternal(throwable.message, throwable) + + override fun reject(throwable: Throwable, userInfo: WritableMap) = rejectInternal(throwable.message, throwable) + + override fun reject(code: String?, userInfo: WritableMap) = rejectInternal(code, null) + + override fun reject(code: String?, throwable: Throwable?, userInfo: WritableMap) = rejectInternal(code, throwable) + + override fun reject(code: String?, message: String?, userInfo: WritableMap) = rejectInternal(message ?: code, null) + + override fun reject(code: String?, message: String?, throwable: Throwable?, userInfo: WritableMap?) = rejectInternal(message ?: code, throwable) + + @Deprecated("Prefer passing a module-specific error code to JS.", ReplaceWith("reject(code, message)")) + override fun reject(message: String) = rejectInternal(message, null) +} diff --git a/android/src/main/java/com/reactnativecompressor/Utils/EventEmitterHandler.kt b/android/src/main/java/com/reactnativecompressor/Utils/EventEmitterHandler.kt index e093aa1e..27ae0982 100644 --- a/android/src/main/java/com/reactnativecompressor/Utils/EventEmitterHandler.kt +++ b/android/src/main/java/com/reactnativecompressor/Utils/EventEmitterHandler.kt @@ -1,66 +1,70 @@ package com.reactnativecompressor.Utils -import com.facebook.react.bridge.Arguments -import com.facebook.react.bridge.ReactApplicationContext -import com.facebook.react.modules.core.DeviceEventManagerModule +import java.util.concurrent.ConcurrentHashMap +/** + * Routes native progress emissions to the per-call JS callbacks that + * `HybridCompressor` registers. This replaces the old `RCTDeviceEventEmitter` + * bridge: Nitro delivers progress through callback parameters, so the domain + * layer's `emit*` calls (unchanged) are dispatched to the callback registered + * under the same `uuid` that the JS layer threads through the options map. + */ class EventEmitterHandler { companion object { - public var reactContext: ReactApplicationContext?=null + private val videoCompressProgressCallbacks = ConcurrentHashMap Unit>() + private val downloadProgressCallbacks = ConcurrentHashMap Unit>() + private val uploadProgressCallbacks = ConcurrentHashMap Unit>() - private fun sendEvent(eventName: String, - params: Any?) { - reactContext - ?.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java) - ?.emit(eventName, params) + @Volatile + private var backgroundTaskExpiredCallback: (() -> Unit)? = null + + // Registration (called by HybridCompressor) + + fun registerVideoCompressProgress(uuid: String, callback: ((Double) -> Unit)?) { + if (callback != null) videoCompressProgressCallbacks[uuid] = callback } - fun emitBackgroundTaskExpired(backgroundId: String?){ - sendEvent("backgroundTaskExpired",backgroundId) + fun registerDownloadProgress(uuid: String, callback: ((Double) -> Unit)?) { + if (callback != null) downloadProgressCallbacks[uuid] = callback } - fun emitVideoCompressProgress(progress:Double,uuid:String){ - val params = Arguments.createMap() - val data = Arguments.createMap() - params.putString("uuid", uuid) - data.putDouble("progress", progress) - params.putMap("data", data) - sendEvent("videoCompressProgress", params) + fun registerUploadProgress(uuid: String, callback: ((Double, Double) -> Unit)?) { + if (callback != null) uploadProgressCallbacks[uuid] = callback } - fun emitDownloadProgress(progress:Double,uuid:String){ - val params = Arguments.createMap() - val data = Arguments.createMap() - params.putString("uuid", uuid) - data.putDouble("progress", progress) - params.putMap("data", data) - sendEvent("downloadProgress", params) + fun unregister(uuid: String) { + videoCompressProgressCallbacks.remove(uuid) + downloadProgressCallbacks.remove(uuid) + uploadProgressCallbacks.remove(uuid) } - fun emitDownloadProgressError(uuid:String?, error:String?){ - if (uuid != null && error != null) { - val params = Arguments.createMap() - val data = Arguments.createMap() - params.putString("uuid", uuid) - params.putString("error", error) - params.putMap("data", data) - sendEvent("downloadProgressError", params) - } + fun setBackgroundTaskExpiredCallback(callback: (() -> Unit)?) { + backgroundTaskExpiredCallback = callback } - fun sendUploadProgressEvent(numBytes: Long, totalBytes: Long, uuid:String?) { - if(uuid!=null) { - val _params = Arguments.createMap() - val _data = Arguments.createMap() - _params.putString("uuid", uuid) - _data.putDouble("written", numBytes.toDouble()) - _data.putDouble("total", totalBytes.toDouble()) - _params.putMap("data", _data) - sendEvent("uploadProgress", _params) - } + // Emission (called by the domain layer — method names preserved) + + fun emitBackgroundTaskExpired(backgroundId: String?) { + backgroundTaskExpiredCallback?.invoke() } + fun emitVideoCompressProgress(progress: Double, uuid: String) { + videoCompressProgressCallbacks[uuid]?.invoke(progress) + } - } + fun emitDownloadProgress(progress: Double, uuid: String) { + downloadProgressCallbacks[uuid]?.invoke(progress) + } + + fun emitDownloadProgressError(uuid: String?, error: String?) { + // No JS consumer for `downloadProgressError`; download failures surface + // through the rejected Promise instead. Kept so domain call sites compile. + } + fun sendUploadProgressEvent(numBytes: Long, totalBytes: Long, uuid: String?) { + if (uuid != null) { + uploadProgressCallbacks[uuid]?.invoke(numBytes.toDouble(), totalBytes.toDouble()) + } + } + } } diff --git a/android/src/main/java/com/reactnativecompressor/Utils/Utils.kt b/android/src/main/java/com/reactnativecompressor/Utils/Utils.kt index 2c7c1fde..db130931 100644 --- a/android/src/main/java/com/reactnativecompressor/Utils/Utils.kt +++ b/android/src/main/java/com/reactnativecompressor/Utils/Utils.kt @@ -134,7 +134,7 @@ object Utils { if (fileSize >= 0) { promise.resolve(fileSize.toString()) } else { - promise.resolve("") + promise.reject("FILE_SIZE_ERROR", "Failed to get file size for path: $filePath") } } } diff --git a/android/src/main/java/com/reactnativecompressor/Video/VideoCompressorHelper.kt b/android/src/main/java/com/reactnativecompressor/Video/VideoCompressorHelper.kt index 6669a27b..d4c46299 100644 --- a/android/src/main/java/com/reactnativecompressor/Video/VideoCompressorHelper.kt +++ b/android/src/main/java/com/reactnativecompressor/Video/VideoCompressorHelper.kt @@ -5,6 +5,7 @@ import android.content.Context import android.media.MediaMetadataRetriever import android.net.Uri import android.os.Handler +import android.os.Looper import android.os.PowerManager import android.os.PowerManager.WakeLock import com.facebook.react.bridge.LifecycleEventListener @@ -58,7 +59,9 @@ class VideoCompressorHelper { if (!wakeLock!!.isHeld()) { wakeLock!!.acquire() } - handler = Handler() + // Bind to the main Looper: under Nitro this runs on a background executor + // thread (no Looper), so the no-arg Handler() constructor would throw. + handler = Handler(Looper.getMainLooper()) runnable = Runnable { } handler!!.post(runnable!!) return "" diff --git a/android/src/newarch/CompressorSpec.kt b/android/src/newarch/CompressorSpec.kt deleted file mode 100644 index a9570499..00000000 --- a/android/src/newarch/CompressorSpec.kt +++ /dev/null @@ -1,5 +0,0 @@ -package com.reactnativecompressor - -import com.facebook.react.bridge.ReactApplicationContext - -abstract class CompressorSpec(context: ReactApplicationContext?) : NativeCompressorSpec(context) diff --git a/android/src/oldarch/CompressorSpec.kt b/android/src/oldarch/CompressorSpec.kt deleted file mode 100644 index b0fa83d2..00000000 --- a/android/src/oldarch/CompressorSpec.kt +++ /dev/null @@ -1,36 +0,0 @@ -package com.reactnativecompressor - -import com.facebook.react.bridge.Promise -import com.facebook.react.bridge.ReactApplicationContext -import com.facebook.react.bridge.ReactContextBaseJavaModule -import com.facebook.react.bridge.ReadableMap - -abstract class CompressorSpec(context: ReactApplicationContext?) : ReactContextBaseJavaModule(context) { - abstract fun image_compress( - imagePath: String, - optionMap: ReadableMap, - promise: Promise) - - abstract fun compress_audio( - fileUrl: String, - optionMap: ReadableMap, - promise: Promise) - - abstract fun generateFilePath(_extension: String, promise: Promise) - abstract fun getRealPath(path: String, type: String, promise: Promise) - abstract fun getVideoMetaData(filePath: String, promise: Promise) - abstract fun getImageMetaData(filePath: String, promise: Promise); - abstract fun getFileSize(filePath: String, promise: Promise) - abstract fun compress(fileUrl: String, optionMap: ReadableMap, promise: Promise) - abstract fun cancelCompression(uuid: String) - abstract fun upload(fileUrl: String, options: ReadableMap, promise: Promise) - abstract fun cancelUpload(uuid: String, shouldCancelAll:Boolean) - - abstract fun download(fileUrl: String, options: ReadableMap, promise: Promise) - abstract fun activateBackgroundTask(options: ReadableMap, promise: Promise) - abstract fun deactivateBackgroundTask(options: ReadableMap, promise: Promise) - abstract fun createVideoThumbnail(fileUrl: String, options: ReadableMap, promise: Promise) - abstract fun clearCache(cacheDir: String?, promise: Promise) - abstract fun addListener(eventName: String) - abstract fun removeListeners(count: Double) -} diff --git a/examples/bare/ios/Podfile.lock b/examples/bare/ios/Podfile.lock index 5c762697..23cdb7fe 100644 --- a/examples/bare/ios/Podfile.lock +++ b/examples/bare/ios/Podfile.lock @@ -3,6 +3,29 @@ PODS: - hermes-engine (250829098.0.10): - hermes-engine/Pre-built (= 250829098.0.10) - hermes-engine/Pre-built (250829098.0.10) + - NitroModules (0.35.9): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-callinvoker + - React-Core + - React-Core-prebuilt + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-ImageManager + - React-jsi + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - Yoga - RCTDeprecation (0.85.2) - RCTRequired (0.85.2) - RCTSwiftUI (0.85.2) @@ -1384,8 +1407,9 @@ PODS: - ReactCommon/turbomodule/core - ReactNativeDependencies - Yoga - - react-native-compressor (1.18.1): + - react-native-compressor (1.19.0): - hermes-engine + - NitroModules - RCTRequired - RCTTypeSafety - React-Core @@ -2280,6 +2304,7 @@ PODS: DEPENDENCIES: - FBLazyVector (from `../node_modules/react-native/Libraries/FBLazyVector`) - hermes-engine (from `../node_modules/react-native/sdks/hermes-engine/hermes-engine.podspec`) + - NitroModules (from `../node_modules/react-native-nitro-modules`) - RCTDeprecation (from `../node_modules/react-native/ReactApple/Libraries/RCTFoundation/RCTDeprecation`) - RCTRequired (from `../node_modules/react-native/Libraries/Required`) - RCTSwiftUI (from `../node_modules/react-native/ReactApple/RCTSwiftUI`) @@ -2372,6 +2397,8 @@ EXTERNAL SOURCES: hermes-engine: :podspec: "../node_modules/react-native/sdks/hermes-engine/hermes-engine.podspec" :tag: hermes-v250829098.0.10 + NitroModules: + :path: "../node_modules/react-native-nitro-modules" RCTDeprecation: :path: "../node_modules/react-native/ReactApple/Libraries/RCTFoundation/RCTDeprecation" RCTRequired: @@ -2543,7 +2570,8 @@ EXTERNAL SOURCES: SPEC CHECKSUMS: FBLazyVector: 26fd21c75314e101f280d401e97f27d54f3f7064 - hermes-engine: eaa65d42895b52c6d680c0aebfccfa50baccce3d + hermes-engine: 0b984fdb4f1be8bb5220ab64fb47ccd0f65ee20d + NitroModules: 16bc17a076b12304d608f7c915b9d321f56dfc19 RCTDeprecation: c7a2768f905d76ca6d2cfefb26e4349eacbdfca3 RCTRequired: 5e502c3553cfbed090a991c444448da452fb752e RCTSwiftUI: 5ce3ccbdc58b78cc4ebbaace01709ec22d58e131 @@ -2552,7 +2580,7 @@ SPEC CHECKSUMS: React: 13cf8451582adb1bb324306e1893b91d1cba28c6 React-callinvoker: 91e6a605826b684ad2e623811253b4d0c4196bef React-Core: 46818de5f211b2a2759ac823b591af8a0a95c2c1 - React-Core-prebuilt: 7393118ba8d9419fbaf6e4083c2ede4837d5d29c + React-Core-prebuilt: 4c2f625ecba9bf71d16289246364b3b05af8fff7 React-CoreModules: a6a37afee48d4a31ab398640b0795462647d5c67 React-cxxreact: 2ec3e2f7a8ae9303460d4ba94cde183ea90d64cd React-debug: 0d21117b897ce0359c9d2c9dfe952f237476a14a @@ -2581,7 +2609,7 @@ SPEC CHECKSUMS: React-Mapbuffer: 1fc10d873f00aa895836c316a9737ce7d43875ce React-microtasksnativemodule: 85ac7286ff84e8fc8e1956233748b8d4b2a6dcea react-native-cameraroll: 4d4fcff8a057235ce7a9f57d4566409207216a86 - react-native-compressor: baa9ad85164b535ecbb2a930e89fb8172cd19575 + react-native-compressor: de8424f653a4d0ff70ebf0a74dc718a277c7b103 react-native-document-picker: 15cca2d1a6bfb6d0d3ff0283bd9b903e57cb028b react-native-get-random-values: 68792987aef40e8aa72dc448d97e009d1f440c88 react-native-image-picker: 23540feacc79c63c60857f318fdfa8477c26e70a @@ -2620,7 +2648,7 @@ SPEC CHECKSUMS: ReactAppDependencyProvider: 22e2265d86a4e871e5e858f4e7ef1c8d01103680 ReactCodegen: 5bd23df5c8ad6c87df0bc8ccd391bd37bf6c92d5 ReactCommon: a804bb8d1dcf3ecdec3a77eb8bba19b7863bbbdb - ReactNativeDependencies: a17cceda80e02709ea6124c4abae3917dc3aa06b + ReactNativeDependencies: 8f281fcd80f3731161fae0a4c851c09bb2a5e79a ReactNativeFs: 21026144ac71a65acf6855c52b1a8eb31012bb5d RNCMaskedView: eb2b2e538afa907f05a5848a1a1ac26092e6fec9 RNReanimated: c4e6659e58b793885ae6da476cb514fc913e7b85 diff --git a/examples/bare/package.json b/examples/bare/package.json index de94642b..27c45019 100644 --- a/examples/bare/package.json +++ b/examples/bare/package.json @@ -3,10 +3,10 @@ "version": "0.0.1", "private": true, "scripts": { - "android": "react-native run-android", + "android": "react-native run-android --port 8082", "ios": "react-native run-ios --simulator \"${IOS_SIMULATOR:-iPhone 17 Pro}\"", "lint": "eslint .", - "start": "react-native start", + "start": "react-native start --port 8082", "test": "jest" }, "dependencies": { @@ -22,6 +22,7 @@ "react-native": "0.85.2", "react-native-get-random-values": "^2.0.0", "react-native-image-picker": "^8.2.1", + "react-native-nitro-modules": "^0.35.9", "react-native-progress": "^5.0.1", "react-native-reanimated": "^4.3.0", "react-native-safe-area-context": "^5.7.0", diff --git a/examples/bare/rn-harness.config.mjs b/examples/bare/rn-harness.config.mjs index 4abc7889..cb713a4d 100644 --- a/examples/bare/rn-harness.config.mjs +++ b/examples/bare/rn-harness.config.mjs @@ -1,7 +1,7 @@ import { androidEmulator, androidPlatform } from '@react-native-harness/platform-android'; import { applePlatform, appleSimulator } from '@react-native-harness/platform-apple'; -const androidDevice = process.env.RN_HARNESS_ANDROID_DEVICE ?? 'Pixel_8_API_35'; +const androidDevice = process.env.RN_HARNESS_ANDROID_DEVICE ?? 'Pixel_9a'; const iosDevice = process.env.RN_HARNESS_IOS_DEVICE ?? 'iPhone 17 Pro'; const iosVersion = process.env.RN_HARNESS_IOS_VERSION ?? '26.4'; diff --git a/examples/expo/package.json b/examples/expo/package.json index 0565366b..576f977d 100644 --- a/examples/expo/package.json +++ b/examples/expo/package.json @@ -24,6 +24,7 @@ "react-dom": "19.1.0", "react-native": "0.81.5", "react-native-get-random-values": "^1.9.0", + "react-native-nitro-modules": "^0.35.9", "react-native-progress": "^5.0.0", "react-native-reanimated": "^4.3.0", "react-native-safe-area-context": "~5.6.0", diff --git a/harness/native-compressor.harness.ts b/harness/native-compressor.harness.ts index e4fddf02..4cd89a27 100644 --- a/harness/native-compressor.harness.ts +++ b/harness/native-compressor.harness.ts @@ -155,7 +155,9 @@ describe('react-native-compressor native harness', () => { expect(thumbnail.mime).toBe('image/jpeg'); expect(thumbnail.width).toBeGreaterThan(0); expect(thumbnail.height).toBeGreaterThan(0); - await expectFileOutput(`file://${thumbnail.path}`); + // iOS returns a raw path; Android returns a `file://`-prefixed path. Normalize so the + // existence check receives a single `file://` scheme on both platforms. + await expectFileOutput(thumbnail.path.startsWith('file://') ? thumbnail.path : `file://${thumbnail.path}`); await expect(clearCache('thumbnails/')).resolves.toBe('done'); }); @@ -169,10 +171,10 @@ describe('react-native-compressor native harness', () => { it('manages exported background task and cancellation APIs without crashing', async () => { const taskId = await Video.activateBackgroundTask(); - expect(taskId === null || typeof taskId === 'number').toBe(true); + expect(typeof taskId).toBe('string'); const deactivatedTask = await Video.deactivateBackgroundTask(); - expect(deactivatedTask == null).toBe(true); + expect(typeof deactivatedTask).toBe('string'); expect(() => Video.cancelCompression('missing-compression-id')).not.toThrow(); expect(() => cancelUpload('', true)).not.toThrow(); }); diff --git a/ios/Audio/AudioMain.swift b/ios/Audio/AudioMain.swift index b3ac4973..766b7369 100644 --- a/ios/Audio/AudioMain.swift +++ b/ios/Audio/AudioMain.swift @@ -7,6 +7,7 @@ import AVFoundation +import React class AudioMain{ static func compress_audio(_ fileUrl: String, optionMap: NSDictionary, resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) { do { diff --git a/ios/Audio/FormatConverter/FormatConverter+Compressed.swift b/ios/Audio/FormatConverter/FormatConverter+Compressed.swift index a437963d..b0a0668e 100644 --- a/ios/Audio/FormatConverter/FormatConverter+Compressed.swift +++ b/ios/Audio/FormatConverter/FormatConverter+Compressed.swift @@ -5,7 +5,7 @@ import AVFoundation // MARK: - internal helper functions -public extension AVURLAsset { +extension AVURLAsset { /// Audio format for the file in the URL asset var audioFormat: AVAudioFormat? { // pull the input format out of the audio file... diff --git a/ios/Audio/FormatConverter/FormatConverter+Utilities.swift b/ios/Audio/FormatConverter/FormatConverter+Utilities.swift index 574aa6c6..47dd6c1d 100644 --- a/ios/Audio/FormatConverter/FormatConverter+Utilities.swift +++ b/ios/Audio/FormatConverter/FormatConverter+Utilities.swift @@ -11,7 +11,7 @@ extension FormatConverter { } } -public extension FormatConverter { +extension FormatConverter { /// Is this file a PCM file? /// - Parameters: /// - url: The URL to parse diff --git a/ios/Audio/FormatConverter/FormatConverter.swift b/ios/Audio/FormatConverter/FormatConverter.swift index 665ab423..df9a2330 100644 --- a/ios/Audio/FormatConverter/FormatConverter.swift +++ b/ios/Audio/FormatConverter/FormatConverter.swift @@ -19,7 +19,9 @@ import AVFoundation } ``` */ -public class FormatConverter { +// `internal` (not `public`): keeps these types out of the Swift↔C++ interop surface +// that Nitro enables module-wide, which otherwise fails to link the nested `Options` type. +class FormatConverter { // MARK: - properties /// The source audio file @@ -122,7 +124,7 @@ public class FormatConverter { // MARK: - Definitions -public enum AudioFileFormat: String { +enum AudioFileFormat: String { case aac case aif case aifc @@ -141,7 +143,7 @@ public enum AudioFileFormat: String { case wav } -public extension FormatConverter { +extension FormatConverter { /// FormatConverterCallback is the callback format for start() /// - Parameter: error This will contain one parameter of type Error which is nil if the conversion was successful. diff --git a/ios/Compressor-Bridging-Header.h b/ios/Compressor-Bridging-Header.h deleted file mode 100644 index b85cffd7..00000000 --- a/ios/Compressor-Bridging-Header.h +++ /dev/null @@ -1,6 +0,0 @@ -// -// Use this file to import your target's public headers that you would like to expose to Swift. -// -#import -#import -#import diff --git a/ios/Compressor.h b/ios/Compressor.h deleted file mode 100644 index ed02c9ba..00000000 --- a/ios/Compressor.h +++ /dev/null @@ -1,5 +0,0 @@ -#import - -#ifdef RCT_NEW_ARCH_ENABLED -#import "RNCompressorSpec.h" -#endif diff --git a/ios/Compressor.mm b/ios/Compressor.mm deleted file mode 100644 index 38e63b74..00000000 --- a/ios/Compressor.mm +++ /dev/null @@ -1,82 +0,0 @@ -#import "Compressor.h" - -@interface RCT_EXTERN_MODULE(Compressor, RCTEventEmitter) - -RCT_EXTERN_METHOD(image_compress: (NSString*) imagePath - withOptions: (NSDictionary*) optionMap - withResolver: (RCTPromiseResolveBlock) resolve - withRejecter: (RCTPromiseRejectBlock) reject) - -RCT_EXTERN_METHOD(getImageMetaData: (NSString*) filePath - withResolver: (RCTPromiseResolveBlock) resolve - withRejecter: (RCTPromiseRejectBlock) reject) - -RCT_EXTERN_METHOD(compress_audio: (NSString*) filePath - withOptions: (NSDictionary*) optionsDict - withResolver: (RCTPromiseResolveBlock) resolve - withRejecter: (RCTPromiseRejectBlock) reject) - -RCT_EXTERN_METHOD(generateFilePath: (NSString*) _extension - withResolver: (RCTPromiseResolveBlock) resolve - withRejecter: (RCTPromiseRejectBlock) reject) - -RCT_EXTERN_METHOD(getRealPath: (NSString*) path - withType: (NSString*) type - withResolver: (RCTPromiseResolveBlock) resolve - withRejecter: (RCTPromiseRejectBlock) reject) - -RCT_EXTERN_METHOD(getFileSize: (NSString*) filePath - withResolver: (RCTPromiseResolveBlock) resolve - withRejecter: (RCTPromiseRejectBlock) reject) - -RCT_EXTERN_METHOD(getVideoMetaData: (NSString*) filePath - withResolver: (RCTPromiseResolveBlock) resolve - withRejecter: (RCTPromiseRejectBlock) reject) - -RCT_EXTERN_METHOD(compress:(NSString *)fileUrl - withOptions:(NSDictionary *)options - withResolver:(RCTPromiseResolveBlock)resolve - withRejecter:(RCTPromiseRejectBlock)reject) - -RCT_EXTERN_METHOD(upload:(NSString *)fileUrl - withOptions:(NSDictionary *)options - withResolver:(RCTPromiseResolveBlock)resolve - withRejecter:(RCTPromiseRejectBlock)reject) - -RCT_EXTERN_METHOD(cancelUpload:(NSString *)uuid - withShouldCancelAll:(BOOL*)shouldCancelAll) - -RCT_EXTERN_METHOD(download:(NSString *)fileUrlu - withOptions:(NSDictionary *)options - withResolver:(RCTPromiseResolveBlock)resolve - withRejecter:(RCTPromiseRejectBlock)reject) - -RCT_EXTERN_METHOD(activateBackgroundTask: (NSDictionary *)options - withResolver:(RCTPromiseResolveBlock)resolve - withRejecter:(RCTPromiseRejectBlock)reject) - -RCT_EXTERN_METHOD(deactivateBackgroundTask: (NSDictionary *)options - withResolver:(RCTPromiseResolveBlock)resolve - withRejecter:(RCTPromiseRejectBlock)reject) - -RCT_EXTERN_METHOD(createVideoThumbnail:(NSString *)fileUrl - withOptions:(NSDictionary *)options - withResolver:(RCTPromiseResolveBlock)resolve - withRejecter:(RCTPromiseRejectBlock)reject) - -RCT_EXTERN_METHOD(clearCache:(NSString *)cacheDir - withResolver:(RCTPromiseResolveBlock)resolve - withRejecter:(RCTPromiseRejectBlock)reject) - -RCT_EXTERN_METHOD(cancelCompression:(NSString *)uuid) - -// Don't compile this code when we build for the old architecture. -#ifdef RCT_NEW_ARCH_ENABLED -- (std::shared_ptr)getTurboModule: - (const facebook::react::ObjCTurboModule::InitParams &)params -{ - return std::make_shared(params); -} -#endif - -@end diff --git a/ios/CompressorManager.swift b/ios/CompressorManager.swift deleted file mode 100644 index 9d4fb6dc..00000000 --- a/ios/CompressorManager.swift +++ /dev/null @@ -1,117 +0,0 @@ -import Foundation -import AVFoundation - -let videoCompressor = VideoCompressor() -let uploader=Uploader() -@objc(Compressor) -class Compressor: RCTEventEmitter { - override static func moduleName() -> String { - return "Compressor" - } - - override init() { - super.init() - EventEmitterHandler.initCompressorInstance(self) - } - - override func stopObserving() -> Void { - EventEmitterHandler.stopObserving() - } - - override func startObserving() -> Void { - EventEmitterHandler.startObserving() - } - - override static func requiresMainQueueSetup() -> Bool { - return false - } - - override func supportedEvents() -> [String] { - return ["downloadProgress", "videoCompressProgress", "uploadProgress", "backgroundTaskExpired"] - } - - @objc(image_compress:withOptions:withResolver:withRejecter:) - func image_compress(_ imagePath: String, optionMap: NSDictionary, resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) { - ImageMain.image_compress(imagePath, optionMap: optionMap, resolve: resolve, reject: reject) - } - - @objc(getImageMetaData:withResolver:withRejecter:) - func getImageMetaData(_ filePath: String, resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) { - ImageMain.getImageMetaData(filePath,resolve: resolve,reject: reject) - } - - @objc(compress_audio:withOptions:withResolver:withRejecter:) - func compress_audio(_ fileUrl: String, optionMap: NSDictionary, resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) { - AudioMain.compress_audio(fileUrl, optionMap: optionMap, resolve: resolve, reject: reject) - } - - @objc(generateFilePath:withResolver:withRejecter:) - func generateFilePath(_ _extension: String, resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) { - Utils.generateFilePath(_extension, resolve: resolve, reject: reject) - } - - @objc(getRealPath:withType:withResolver:withRejecter:) - func getRealPath(_ path: String, type: String, resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) { - Utils.getRealPath(path, type: type, resolve: resolve, reject: reject) - } - - @objc(getFileSize:withResolver:withRejecter:) - func getFileSize(_ filePath: String, resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) { - Utils.getFileSize(filePath, resolve: resolve, reject: reject) - } - - @objc(getVideoMetaData:withResolver:withRejecter:) - func getVideoMetaData(_ filePath: String, resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) { - videoCompressor.getVideoMetaData(filePath,resolve: resolve,reject: reject) - } - - - @objc(activateBackgroundTask:withResolver:withRejecter:) - func activateBackgroundTask(options: [String: Any], resolve:@escaping RCTPromiseResolveBlock, reject:@escaping RCTPromiseRejectBlock) -> Void { - videoCompressor.activateBackgroundTask(options: options,resolve: resolve,reject: reject) - } - - @objc(deactivateBackgroundTask:withResolver:withRejecter:) - func deactivateBackgroundTask(options: [String: Any], resolve:@escaping RCTPromiseResolveBlock, reject:@escaping RCTPromiseRejectBlock) -> Void { - videoCompressor.deactivateBackgroundTask(options: options,resolve: resolve,reject: reject) - } - - @objc(compress:withOptions:withResolver:withRejecter:) - func compress(fileUrl: String, options: [String: Any], resolve:@escaping RCTPromiseResolveBlock, reject:@escaping RCTPromiseRejectBlock) -> Void { - videoCompressor.compress(fileUrl: fileUrl, options: options, resolve: resolve, reject: reject) - } - - @objc(cancelCompression:) - func cancelCompression(uuid: String) -> Void { - videoCompressor.cancelCompression(uuid: uuid) - } - - @objc(upload:withOptions:withResolver:withRejecter:) - func upload(filePath: String, options: [String: Any], resolve:@escaping RCTPromiseResolveBlock, reject:@escaping RCTPromiseRejectBlock) -> Void { - uploader.upload(filePath: filePath, options: options, resolve: resolve, reject: reject) - } - - @objc(cancelUpload:withShouldCancelAll:) - func cancelUpload(uuid: String,shouldCancelAll:Bool) -> Void { - uploader.cancelUpload(uuid: uuid,shouldCancelAll: shouldCancelAll) - } - - @objc(download:withOptions:withResolver:withRejecter:) - func download(filePath: String, options: [String: Any], resolve:@escaping RCTPromiseResolveBlock, reject:@escaping RCTPromiseRejectBlock) -> Void { - Downloader.downloadFileAndSaveToCache(filePath, uuid: options["uuid"] as! String,progressDivider: options["progressDivider"] as? Int ?? 0) { downloadedPath in - resolve(downloadedPath) - } - } - - @objc(createVideoThumbnail:withOptions:withResolver:withRejecter:) - func createVideoThumbnail(fileUrl: String, options: NSDictionary, resolve:@escaping RCTPromiseResolveBlock, reject:@escaping RCTPromiseRejectBlock) -> Void { - let videoThumbnail=CreateVideoThumbnail() - videoThumbnail.create(fileUrl,options: options, resolve: resolve, rejecter: reject) - } - - @objc(clearCache:withResolver:withRejecter:) - func clearCache(cacheDir: String, resolve:@escaping RCTPromiseResolveBlock, reject:@escaping RCTPromiseRejectBlock) -> Void { - CreateVideoThumbnail.cleanCacheDir(cacheDir: cacheDir,resolve: resolve,rejecter: reject) - } - -} diff --git a/ios/HybridCompressor.swift b/ios/HybridCompressor.swift new file mode 100644 index 00000000..70dab99f --- /dev/null +++ b/ios/HybridCompressor.swift @@ -0,0 +1,240 @@ +// +// HybridCompressor.swift +// react-native-compressor +// +// Nitro HybridObject implementation of the single `Compressor` native module. +// It is a thin binding layer: it converts Nitro's `AnyMap` options into the +// `NSDictionary`/`[String: Any]` the existing domain code already consumes, +// bridges the Nitro `Promise` to the domain layer's `RCTPromiseResolveBlock`/ +// `RCTPromiseRejectBlock`, and registers progress callbacks (keyed by `uuid`) +// with `EventEmitterHandler`. All heavy logic stays in the domain classes. +// + +import Foundation +import NitroModules +import React + +private let videoCompressor = VideoCompressor() +private let uploader = Uploader() + +final class HybridCompressor: HybridCompressorSpec { + // MARK: - Image + + func image_compress(imagePath: String, optionMap: AnyMap, onDownloadProgress: ((Double) -> Void)?) throws -> Promise { + let promise = Promise() + var options = dictionary(from: optionMap) + // Remote-image download progress is keyed by `uuid` inside the Downloader. The JS + // layer no longer sends one (the callback is passed directly), so mint one here. + let uuid = (options["uuid"] as? String) ?? UUID().uuidString + options["uuid"] = uuid + EventEmitterHandler.registerDownloadProgress(uuid: uuid, onDownloadProgress) + ImageMain.image_compress(imagePath, optionMap: options as NSDictionary, resolve: resolveString(promise, uuid), reject: reject(promise, uuid)) + return promise + } + + func getImageMetaData(filePath: String) throws -> Promise { + let promise = Promise() + ImageMain.getImageMetaData(filePath, resolve: resolveAnyMap(promise, nil), reject: reject(promise, nil)) + return promise + } + + // MARK: - Video + + func compress(fileUrl: String, optionMap: AnyMap, onProgress: ((Double) -> Void)?, onDownloadProgress: ((Double) -> Void)?) throws -> Promise { + let promise = Promise() + let options = dictionary(from: optionMap) + let uuid = (options["uuid"] as? String) ?? "" + EventEmitterHandler.registerVideoCompressProgress(uuid: uuid, onProgress) + EventEmitterHandler.registerDownloadProgress(uuid: uuid, onDownloadProgress) + videoCompressor.compress(fileUrl: fileUrl, options: options, resolve: resolveString(promise, uuid), reject: reject(promise, uuid)) + return promise + } + + func cancelCompression(uuid: String) throws { + videoCompressor.cancelCompression(uuid: uuid) + } + + func getVideoMetaData(filePath: String) throws -> Promise { + let promise = Promise() + videoCompressor.getVideoMetaData(filePath, resolve: resolveAnyMap(promise, nil), reject: reject(promise, nil)) + return promise + } + + func activateBackgroundTask(options: AnyMap, onExpired: (() -> Void)?) throws -> Promise { + let promise = Promise() + EventEmitterHandler.setBackgroundTaskExpiredCallback(onExpired) + videoCompressor.activateBackgroundTask(options: dictionary(from: options), resolve: resolveString(promise, nil), reject: reject(promise, nil)) + return promise + } + + func deactivateBackgroundTask(options: AnyMap) throws -> Promise { + let promise = Promise() + EventEmitterHandler.setBackgroundTaskExpiredCallback(nil) + videoCompressor.deactivateBackgroundTask(options: dictionary(from: options), resolve: resolveString(promise, nil), reject: reject(promise, nil)) + return promise + } + + // MARK: - Audio + + func compress_audio(fileUrl: String, optionMap: AnyMap) throws -> Promise { + let promise = Promise() + AudioMain.compress_audio(fileUrl, optionMap: dictionary(from: optionMap) as NSDictionary, resolve: resolveString(promise, nil), reject: reject(promise, nil)) + return promise + } + + // MARK: - Upload / Download + + func upload(fileUrl: String, options: AnyMap, onProgress: ((Double, Double) -> Void)?) throws -> Promise { + let promise = Promise() + let dict = dictionary(from: options) + let uuid = (dict["uuid"] as? String) ?? "" + EventEmitterHandler.registerUploadProgress(uuid: uuid, onProgress) + uploader.upload(filePath: fileUrl, options: dict, resolve: resolveAnyMap(promise, uuid), reject: reject(promise, uuid)) + return promise + } + + func cancelUpload(uuid: String, shouldCancelAll: Bool) throws { + uploader.cancelUpload(uuid: uuid, shouldCancelAll: shouldCancelAll) + } + + func download(fileUrl: String, options: AnyMap, onProgress: ((Double) -> Void)?) throws -> Promise { + let promise = Promise() + let dict = dictionary(from: options) + let uuid = (dict["uuid"] as? String) ?? "" + let progressDivider = (dict["progressDivider"] as? NSNumber)?.intValue ?? 0 + EventEmitterHandler.registerDownloadProgress(uuid: uuid, onProgress) + Downloader.downloadFileAndSaveToCache(fileUrl, uuid: uuid, progressDivider: progressDivider) { downloadedPath in + EventEmitterHandler.unregister(uuid: uuid) + promise.resolve(withResult: downloadedPath) + } + return promise + } + + // MARK: - Others + + func generateFilePath(fileExtension: String) throws -> Promise { + let promise = Promise() + Utils.generateFilePath(fileExtension, resolve: resolveString(promise, nil), reject: reject(promise, nil)) + return promise + } + + func getRealPath(path: String, type: String) throws -> Promise { + let promise = Promise() + Utils.getRealPath(path, type: type, resolve: resolveString(promise, nil), reject: reject(promise, nil)) + return promise + } + + func getFileSize(filePath: String) throws -> Promise { + let promise = Promise() + Utils.getFileSize(filePath, resolve: resolveString(promise, nil), reject: reject(promise, nil)) + return promise + } + + func createVideoThumbnail(fileUrl: String, options: AnyMap) throws -> Promise { + let promise = Promise() + let thumbnail = CreateVideoThumbnail() + thumbnail.create(fileUrl, options: dictionary(from: options) as NSDictionary, resolve: { value in + let dict = HybridCompressor.stringKeyedDictionary(value) + let result = VideoThumbnailResult( + path: dict["path"] as? String ?? "", + size: HybridCompressor.doubleValue(dict["size"]), + mime: dict["mime"] as? String ?? "", + width: HybridCompressor.doubleValue(dict["width"]), + height: HybridCompressor.doubleValue(dict["height"]) + ) + promise.resolve(withResult: result) + }, rejecter: reject(promise, nil)) + return promise + } + + func clearCache(cacheDir: String?) throws -> Promise { + let promise = Promise() + CreateVideoThumbnail.cleanCacheDir(cacheDir: cacheDir ?? "", resolve: resolveString(promise, nil), rejecter: reject(promise, nil)) + return promise + } + + // MARK: - Helpers + + /// Convert a Nitro `AnyMap` to a `[String: Any]`, round-tripping through + /// `NSDictionary` so numbers are boxed as `NSNumber` (the domain parsers rely + /// on `as? Int` / `as? Bool`, which fail on a bare Swift `Double`). + private func dictionary(from map: AnyMap) -> [String: Any] { + let normalized = (HybridCompressor.normalize(map.toDictionary()) as? [String: Any]) ?? [:] + return ((normalized as NSDictionary) as? [String: Any]) ?? normalized + } + + private func resolveString(_ promise: Promise, _ uuid: String?) -> RCTPromiseResolveBlock { + return { value in + if let uuid = uuid { EventEmitterHandler.unregister(uuid: uuid) } + promise.resolve(withResult: value as? String ?? "") + } + } + + private func resolveAnyMap(_ promise: Promise, _ uuid: String?) -> RCTPromiseResolveBlock { + return { value in + if let uuid = uuid { EventEmitterHandler.unregister(uuid: uuid) } + promise.resolve(withResult: AnyMap.fromDictionaryIgnoreIncompatible(HybridCompressor.stringKeyedOptionalDictionary(value))) + } + } + + private func reject(_ promise: Promise, _ uuid: String?) -> RCTPromiseRejectBlock { + return { code, message, error in + if let uuid = uuid { EventEmitterHandler.unregister(uuid: uuid) } + let nsError = error ?? RuntimeError.error(withMessage: message ?? code ?? "react-native-compressor error") + promise.reject(withError: nsError) + } + } + + /// Recursively strip optionals so a `[String: Any?]` (what `AnyMap.toDictionary()` + /// returns, including nested objects/arrays) becomes a plain `[String: Any]`. + private static func normalize(_ value: Any?) -> Any? { + switch value { + case let dict as [String: Any?]: + var out = [String: Any]() + for (key, nested) in dict { + if let nestedValue = normalize(nested) { out[key] = nestedValue } + } + return out + case let array as [Any?]: + return array.compactMap { normalize($0) } + default: + return value + } + } + + private static func stringKeyedDictionary(_ value: Any?) -> [String: Any] { + if let dict = value as? [String: Any] { return dict } + if let ns = value as? NSDictionary { + var out = [String: Any]() + for (key, val) in ns where key is String { + out[key as! String] = val + } + return out + } + return [:] + } + + private static func stringKeyedOptionalDictionary(_ value: Any?) -> [String: Any?] { + if let ns = value as? NSDictionary { + var out = [String: Any?]() + for (key, val) in ns where key is String { + out[key as! String] = val + } + return out + } + if let dict = value as? [String: Any] { + var out = [String: Any?]() + for (key, val) in dict { out[key] = val } + return out + } + return [:] + } + + private static func doubleValue(_ value: Any?) -> Double { + if let number = value as? NSNumber { return number.doubleValue } + if let double = value as? Double { return double } + if let float = value as? Float { return Double(float) } + if let int = value as? Int { return Double(int) } + return 0 + } +} diff --git a/ios/Image/ImageMain.swift b/ios/Image/ImageMain.swift index 2e6e9fc0..14008134 100644 --- a/ios/Image/ImageMain.swift +++ b/ios/Image/ImageMain.swift @@ -6,6 +6,7 @@ // import Foundation +import React class ImageMain { static func image_compress(_ value: String, optionMap: NSDictionary, resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) { do { diff --git a/ios/Utils/CreateVideoThumbnail.swift b/ios/Utils/CreateVideoThumbnail.swift index 9282b923..7bfa7c1f 100644 --- a/ios/Utils/CreateVideoThumbnail.swift +++ b/ios/Utils/CreateVideoThumbnail.swift @@ -8,6 +8,7 @@ import Foundation import AVFoundation import UIKit +import React class CreateVideoThumbnail: NSObject { private static let defaultQuality = 0.9 diff --git a/ios/Utils/EventEmitterHandler.swift b/ios/Utils/EventEmitterHandler.swift index 281c7de0..c0ab872f 100644 --- a/ios/Utils/EventEmitterHandler.swift +++ b/ios/Utils/EventEmitterHandler.swift @@ -6,49 +6,71 @@ // import Foundation +import UIKit +/// Routes native progress emissions to the per-call JS callbacks that +/// `HybridCompressor` registers. This replaces the old `RCTEventEmitter` bridge: +/// Nitro delivers progress through callback parameters, so the domain layer's +/// `emit*` calls (unchanged) are dispatched to the callback registered under the +/// same `uuid` that the JS layer threads through the options map. class EventEmitterHandler { - static var sharedCompressorObject: Any! - static var hasListener: Bool=false - - static func initCompressorInstance(_ object: Any) { - sharedCompressorObject = object + private static let lock = NSLock() + private static var videoCompressProgressCallbacks = [String: (Double) -> Void]() + private static var downloadProgressCallbacks = [String: (Double) -> Void]() + private static var uploadProgressCallbacks = [String: (Double, Double) -> Void]() + private static var backgroundTaskExpiredCallback: (() -> Void)? + + // MARK: - Registration (called by HybridCompressor) + + static func registerVideoCompressProgress(uuid: String, _ callback: ((Double) -> Void)?) { + guard let callback = callback else { return } + lock.lock(); defer { lock.unlock() } + videoCompressProgressCallbacks[uuid] = callback + } + + static func registerDownloadProgress(uuid: String, _ callback: ((Double) -> Void)?) { + guard let callback = callback else { return } + lock.lock(); defer { lock.unlock() } + downloadProgressCallbacks[uuid] = callback } - - - static func stopObserving() -> Void { - hasListener = false + + static func registerUploadProgress(uuid: String, _ callback: ((Double, Double) -> Void)?) { + guard let callback = callback else { return } + lock.lock(); defer { lock.unlock() } + uploadProgressCallbacks[uuid] = callback + } + + static func unregister(uuid: String) { + lock.lock(); defer { lock.unlock() } + videoCompressProgressCallbacks[uuid] = nil + downloadProgressCallbacks[uuid] = nil + uploadProgressCallbacks[uuid] = nil } - static func startObserving() -> Void { - hasListener = true + static func setBackgroundTaskExpiredCallback(_ callback: (() -> Void)?) { + lock.lock(); defer { lock.unlock() } + backgroundTaskExpiredCallback = callback } - + + // MARK: - Emission (called by the domain layer — method names preserved) + static func emitDownloadProgress(_ progress: NSNumber, uuid: String) { - var params = [String: Any]() - var data = [String: Any]() - params["uuid"] = uuid - data["progress"] = progress - params["data"] = data - - (sharedCompressorObject as AnyObject).sendEvent(withName: "downloadProgress", body: params) - } - + lock.lock(); let callback = downloadProgressCallbacks[uuid]; lock.unlock() + callback?(progress.doubleValue) + } + static func emitVideoCompressProgress(_ progress: Float, uuid: String) { - if(self.hasListener){ - (sharedCompressorObject as AnyObject).sendEvent(withName: "videoCompressProgress", body: ["uuid": uuid, "data": ["progress": progress]]) - } + lock.lock(); let callback = videoCompressProgressCallbacks[uuid]; lock.unlock() + callback?(Double(progress)) } - + static func emituploadProgress(_ uuid: String, totalBytesSent: Int64, totalBytesExpectedToSend: Int64) { - if(self.hasListener){ - (sharedCompressorObject as AnyObject).sendEvent(withName: "uploadProgress", body: ["uuid": uuid, "data": ["written": totalBytesSent, "total": totalBytesExpectedToSend]]) - } - } - - static func emitBackgroundTaskExpired(_ backgroundTaskId:UIBackgroundTaskIdentifier) { - if(self.hasListener){ - (sharedCompressorObject as AnyObject).sendEvent(withName: "backgroundTaskExpired", body: ["backgroundTaskId": backgroundTaskId]) - } + lock.lock(); let callback = uploadProgressCallbacks[uuid]; lock.unlock() + callback?(Double(totalBytesSent), Double(totalBytesExpectedToSend)) + } + + static func emitBackgroundTaskExpired(_ backgroundTaskId: UIBackgroundTaskIdentifier) { + lock.lock(); let callback = backgroundTaskExpiredCallback; lock.unlock() + callback?() } } diff --git a/ios/Utils/Uploader.swift b/ios/Utils/Uploader.swift index 2e6cd0aa..a62e83af 100644 --- a/ios/Utils/Uploader.swift +++ b/ios/Utils/Uploader.swift @@ -7,6 +7,7 @@ import Foundation import MobileCoreServices +import React enum UploaderUploadType: Int { case UploaderInvalidType = -1 diff --git a/ios/Utils/Utils.swift b/ios/Utils/Utils.swift index a08fa920..61f9e1ea 100644 --- a/ios/Utils/Utils.swift +++ b/ios/Utils/Utils.swift @@ -6,6 +6,7 @@ // import Foundation +import React class Utils { static func generateCacheFilePath(_ extension: String) -> String { diff --git a/ios/Video/VideoMain.swift b/ios/Video/VideoMain.swift index 3e29497d..8759d067 100644 --- a/ios/Video/VideoMain.swift +++ b/ios/Video/VideoMain.swift @@ -2,6 +2,8 @@ import Foundation import AVFoundation import Photos import MobileCoreServices +import UIKit +import React struct CompressionError: Error { private let message: String diff --git a/nitro.json b/nitro.json new file mode 100644 index 00000000..d7515c6f --- /dev/null +++ b/nitro.json @@ -0,0 +1,24 @@ +{ + "$schema": "https://nitro.margelo.com/nitro.schema.json", + "cxxNamespace": ["compressor"], + "ios": { + "iosModuleName": "react_native_compressor" + }, + "android": { + "androidNamespace": ["compressor"], + "androidCxxLibName": "NitroCompressor" + }, + "autolinking": { + "Compressor": { + "ios": { + "language": "swift", + "implementationClassName": "HybridCompressor" + }, + "android": { + "language": "kotlin", + "implementationClassName": "HybridCompressor" + } + } + }, + "ignorePaths": ["**/node_modules"] +} diff --git a/nitrogen/generated/.gitattributes b/nitrogen/generated/.gitattributes new file mode 100644 index 00000000..fb7a0d5a --- /dev/null +++ b/nitrogen/generated/.gitattributes @@ -0,0 +1 @@ +** linguist-generated=true diff --git a/nitrogen/generated/android/NitroCompressor+autolinking.cmake b/nitrogen/generated/android/NitroCompressor+autolinking.cmake new file mode 100644 index 00000000..c5af8c26 --- /dev/null +++ b/nitrogen/generated/android/NitroCompressor+autolinking.cmake @@ -0,0 +1,81 @@ +# +# NitroCompressor+autolinking.cmake +# This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +# https://github.com/mrousavy/nitro +# Copyright © Marc Rousavy @ Margelo +# + +# This is a CMake file that adds all files generated by Nitrogen +# to the current CMake project. +# +# To use it, add this to your CMakeLists.txt: +# ```cmake +# include(${CMAKE_SOURCE_DIR}/../nitrogen/generated/android/NitroCompressor+autolinking.cmake) +# ``` + +# Define a flag to check if we are building properly +add_definitions(-DBUILDING_NITROCOMPRESSOR_WITH_GENERATED_CMAKE_PROJECT) + +# Enable Raw Props parsing in react-native (for Nitro Views) +add_definitions(-DRN_SERIALIZABLE_STATE) + +# Add all headers that were generated by Nitrogen +include_directories( + "../nitrogen/generated/shared/c++" + "../nitrogen/generated/android/c++" + "../nitrogen/generated/android/" +) + +# Add all .cpp sources that were generated by Nitrogen +target_sources( + # CMake project name (Android C++ library name) + NitroCompressor PRIVATE + # Autolinking Setup + ../nitrogen/generated/android/NitroCompressorOnLoad.cpp + # Shared Nitrogen C++ sources + ../nitrogen/generated/shared/c++/HybridCompressorSpec.cpp + # Android-specific Nitrogen C++ sources + ../nitrogen/generated/android/c++/JHybridCompressorSpec.cpp +) + +# From node_modules/react-native/ReactAndroid/cmake-utils/folly-flags.cmake +# Used in node_modules/react-native/ReactAndroid/cmake-utils/ReactNative-application.cmake +target_compile_definitions( + NitroCompressor PRIVATE + -DFOLLY_NO_CONFIG=1 + -DFOLLY_HAVE_CLOCK_GETTIME=1 + -DFOLLY_USE_LIBCPP=1 + -DFOLLY_CFG_NO_COROUTINES=1 + -DFOLLY_MOBILE=1 + -DFOLLY_HAVE_RECVMMSG=1 + -DFOLLY_HAVE_PTHREAD=1 + # Once we target android-23 above, we can comment + # the following line. NDK uses GNU style stderror_r() after API 23. + -DFOLLY_HAVE_XSI_STRERROR_R=1 +) + +# Add all libraries required by the generated specs +find_package(fbjni REQUIRED) # <-- Used for communication between Java <-> C++ +find_package(ReactAndroid REQUIRED) # <-- Used to set up React Native bindings (e.g. CallInvoker/TurboModule) +find_package(react-native-nitro-modules REQUIRED) # <-- Used to create all HybridObjects and use the Nitro core library + +# Link all libraries together +target_link_libraries( + NitroCompressor + fbjni::fbjni # <-- Facebook C++ JNI helpers + ReactAndroid::jsi # <-- RN: JSI + react-native-nitro-modules::NitroModules # <-- NitroModules Core :) +) + +# Link react-native (different prefab between RN 0.75 and RN 0.76) +if(ReactAndroid_VERSION_MINOR GREATER_EQUAL 76) + target_link_libraries( + NitroCompressor + ReactAndroid::reactnative # <-- RN: Native Modules umbrella prefab + ) +else() + target_link_libraries( + NitroCompressor + ReactAndroid::react_nativemodule_core # <-- RN: TurboModules Core + ) +endif() diff --git a/nitrogen/generated/android/NitroCompressor+autolinking.gradle b/nitrogen/generated/android/NitroCompressor+autolinking.gradle new file mode 100644 index 00000000..25ce25da --- /dev/null +++ b/nitrogen/generated/android/NitroCompressor+autolinking.gradle @@ -0,0 +1,27 @@ +/// +/// NitroCompressor+autolinking.gradle +/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +/// https://github.com/mrousavy/nitro +/// Copyright © Marc Rousavy @ Margelo +/// + +/// This is a Gradle file that adds all files generated by Nitrogen +/// to the current Gradle project. +/// +/// To use it, add this to your build.gradle: +/// ```gradle +/// apply from: '../nitrogen/generated/android/NitroCompressor+autolinking.gradle' +/// ``` + +logger.warn("[NitroModules] 🔥 NitroCompressor is boosted by nitro!") + +android { + sourceSets { + main { + java.srcDirs += [ + // Nitrogen files + "${project.projectDir}/../nitrogen/generated/android/kotlin" + ] + } + } +} diff --git a/nitrogen/generated/android/NitroCompressorOnLoad.cpp b/nitrogen/generated/android/NitroCompressorOnLoad.cpp new file mode 100644 index 00000000..474ef98a --- /dev/null +++ b/nitrogen/generated/android/NitroCompressorOnLoad.cpp @@ -0,0 +1,60 @@ +/// +/// NitroCompressorOnLoad.cpp +/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +/// https://github.com/mrousavy/nitro +/// Copyright © Marc Rousavy @ Margelo +/// + +#ifndef BUILDING_NITROCOMPRESSOR_WITH_GENERATED_CMAKE_PROJECT +#error NitroCompressorOnLoad.cpp is not being built with the autogenerated CMakeLists.txt project. Is a different CMakeLists.txt building this? +#endif + +#include "NitroCompressorOnLoad.hpp" + +#include +#include +#include + +#include "JHybridCompressorSpec.hpp" +#include "JFunc_void_double.hpp" +#include "JFunc_void.hpp" +#include "JFunc_void_double_double.hpp" +#include + +namespace margelo::nitro::compressor { + +int initialize(JavaVM* vm) { + return facebook::jni::initialize(vm, []() { + ::margelo::nitro::compressor::registerAllNatives(); + }); +} + +struct JHybridCompressorSpecImpl: public jni::JavaClass { + static constexpr auto kJavaDescriptor = "Lcom/margelo/nitro/compressor/HybridCompressor;"; + static std::shared_ptr create() { + static const auto constructorFn = javaClassStatic()->getConstructor(); + jni::local_ref javaPart = javaClassStatic()->newObject(constructorFn); + return javaPart->getJHybridCompressorSpec(); + } +}; + +void registerAllNatives() { + using namespace margelo::nitro; + using namespace margelo::nitro::compressor; + + // Register native JNI methods + margelo::nitro::compressor::JHybridCompressorSpec::CxxPart::registerNatives(); + margelo::nitro::compressor::JFunc_void_double_cxx::registerNatives(); + margelo::nitro::compressor::JFunc_void_cxx::registerNatives(); + margelo::nitro::compressor::JFunc_void_double_double_cxx::registerNatives(); + + // Register Nitro Hybrid Objects + HybridObjectRegistry::registerHybridObjectConstructor( + "Compressor", + []() -> std::shared_ptr { + return JHybridCompressorSpecImpl::create(); + } + ); +} + +} // namespace margelo::nitro::compressor diff --git a/nitrogen/generated/android/NitroCompressorOnLoad.hpp b/nitrogen/generated/android/NitroCompressorOnLoad.hpp new file mode 100644 index 00000000..a8415f5f --- /dev/null +++ b/nitrogen/generated/android/NitroCompressorOnLoad.hpp @@ -0,0 +1,34 @@ +/// +/// NitroCompressorOnLoad.hpp +/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +/// https://github.com/mrousavy/nitro +/// Copyright © Marc Rousavy @ Margelo +/// + +#include +#include +#include + +namespace margelo::nitro::compressor { + + [[deprecated("Use registerNatives() instead.")]] + int initialize(JavaVM* vm); + + /** + * Register the native (C++) part of NitroCompressor, and autolinks all Hybrid Objects. + * Call this in your `JNI_OnLoad` function (probably inside `cpp-adapter.cpp`), + * inside a `facebook::jni::initialize(vm, ...)` call. + * Example: + * ```cpp (cpp-adapter.cpp) + * JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM* vm, void*) { + * return facebook::jni::initialize(vm, []() { + * // register all NitroCompressor HybridObjects + * margelo::nitro::compressor::registerNatives(); + * // any other custom registrations go here. + * }); + * } + * ``` + */ + void registerAllNatives(); + +} // namespace margelo::nitro::compressor diff --git a/nitrogen/generated/android/c++/JFunc_void.hpp b/nitrogen/generated/android/c++/JFunc_void.hpp new file mode 100644 index 00000000..ad9bc2d6 --- /dev/null +++ b/nitrogen/generated/android/c++/JFunc_void.hpp @@ -0,0 +1,75 @@ +/// +/// JFunc_void.hpp +/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +/// https://github.com/mrousavy/nitro +/// Copyright © Marc Rousavy @ Margelo +/// + +#pragma once + +#include +#include + +#include +#include + +namespace margelo::nitro::compressor { + + using namespace facebook; + + /** + * Represents the Java/Kotlin callback `() -> Unit`. + * This can be passed around between C++ and Java/Kotlin. + */ + struct JFunc_void: public jni::JavaClass { + public: + static constexpr auto kJavaDescriptor = "Lcom/margelo/nitro/compressor/Func_void;"; + + public: + /** + * Invokes the function this `JFunc_void` instance holds through JNI. + */ + void invoke() const { + static const auto method = javaClassStatic()->getMethod("invoke"); + method(self()); + } + }; + + /** + * An implementation of Func_void that is backed by a C++ implementation (using `std::function<...>`) + */ + class JFunc_void_cxx final: public jni::HybridClass { + public: + static jni::local_ref fromCpp(const std::function& func) { + return JFunc_void_cxx::newObjectCxxArgs(func); + } + + public: + /** + * Invokes the C++ `std::function<...>` this `JFunc_void_cxx` instance holds. + */ + void invoke_cxx() { + _func(); + } + + public: + [[nodiscard]] + inline const std::function& getFunction() const { + return _func; + } + + public: + static constexpr auto kJavaDescriptor = "Lcom/margelo/nitro/compressor/Func_void_cxx;"; + static void registerNatives() { + registerHybrid({makeNativeMethod("invoke_cxx", JFunc_void_cxx::invoke_cxx)}); + } + + private: + explicit JFunc_void_cxx(const std::function& func): _func(func) { } + + private: + friend HybridBase; + std::function _func; + }; + +} // namespace margelo::nitro::compressor diff --git a/nitrogen/generated/android/c++/JFunc_void_double.hpp b/nitrogen/generated/android/c++/JFunc_void_double.hpp new file mode 100644 index 00000000..8f29be79 --- /dev/null +++ b/nitrogen/generated/android/c++/JFunc_void_double.hpp @@ -0,0 +1,75 @@ +/// +/// JFunc_void_double.hpp +/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +/// https://github.com/mrousavy/nitro +/// Copyright © Marc Rousavy @ Margelo +/// + +#pragma once + +#include +#include + +#include +#include + +namespace margelo::nitro::compressor { + + using namespace facebook; + + /** + * Represents the Java/Kotlin callback `(progress: Double) -> Unit`. + * This can be passed around between C++ and Java/Kotlin. + */ + struct JFunc_void_double: public jni::JavaClass { + public: + static constexpr auto kJavaDescriptor = "Lcom/margelo/nitro/compressor/Func_void_double;"; + + public: + /** + * Invokes the function this `JFunc_void_double` instance holds through JNI. + */ + void invoke(double progress) const { + static const auto method = javaClassStatic()->getMethod("invoke"); + method(self(), progress); + } + }; + + /** + * An implementation of Func_void_double that is backed by a C++ implementation (using `std::function<...>`) + */ + class JFunc_void_double_cxx final: public jni::HybridClass { + public: + static jni::local_ref fromCpp(const std::function& func) { + return JFunc_void_double_cxx::newObjectCxxArgs(func); + } + + public: + /** + * Invokes the C++ `std::function<...>` this `JFunc_void_double_cxx` instance holds. + */ + void invoke_cxx(double progress) { + _func(progress); + } + + public: + [[nodiscard]] + inline const std::function& getFunction() const { + return _func; + } + + public: + static constexpr auto kJavaDescriptor = "Lcom/margelo/nitro/compressor/Func_void_double_cxx;"; + static void registerNatives() { + registerHybrid({makeNativeMethod("invoke_cxx", JFunc_void_double_cxx::invoke_cxx)}); + } + + private: + explicit JFunc_void_double_cxx(const std::function& func): _func(func) { } + + private: + friend HybridBase; + std::function _func; + }; + +} // namespace margelo::nitro::compressor diff --git a/nitrogen/generated/android/c++/JFunc_void_double_double.hpp b/nitrogen/generated/android/c++/JFunc_void_double_double.hpp new file mode 100644 index 00000000..ff060b6c --- /dev/null +++ b/nitrogen/generated/android/c++/JFunc_void_double_double.hpp @@ -0,0 +1,75 @@ +/// +/// JFunc_void_double_double.hpp +/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +/// https://github.com/mrousavy/nitro +/// Copyright © Marc Rousavy @ Margelo +/// + +#pragma once + +#include +#include + +#include +#include + +namespace margelo::nitro::compressor { + + using namespace facebook; + + /** + * Represents the Java/Kotlin callback `(written: Double, total: Double) -> Unit`. + * This can be passed around between C++ and Java/Kotlin. + */ + struct JFunc_void_double_double: public jni::JavaClass { + public: + static constexpr auto kJavaDescriptor = "Lcom/margelo/nitro/compressor/Func_void_double_double;"; + + public: + /** + * Invokes the function this `JFunc_void_double_double` instance holds through JNI. + */ + void invoke(double written, double total) const { + static const auto method = javaClassStatic()->getMethod("invoke"); + method(self(), written, total); + } + }; + + /** + * An implementation of Func_void_double_double that is backed by a C++ implementation (using `std::function<...>`) + */ + class JFunc_void_double_double_cxx final: public jni::HybridClass { + public: + static jni::local_ref fromCpp(const std::function& func) { + return JFunc_void_double_double_cxx::newObjectCxxArgs(func); + } + + public: + /** + * Invokes the C++ `std::function<...>` this `JFunc_void_double_double_cxx` instance holds. + */ + void invoke_cxx(double written, double total) { + _func(written, total); + } + + public: + [[nodiscard]] + inline const std::function& getFunction() const { + return _func; + } + + public: + static constexpr auto kJavaDescriptor = "Lcom/margelo/nitro/compressor/Func_void_double_double_cxx;"; + static void registerNatives() { + registerHybrid({makeNativeMethod("invoke_cxx", JFunc_void_double_double_cxx::invoke_cxx)}); + } + + private: + explicit JFunc_void_double_double_cxx(const std::function& func): _func(func) { } + + private: + friend HybridBase; + std::function _func; + }; + +} // namespace margelo::nitro::compressor diff --git a/nitrogen/generated/android/c++/JHybridCompressorSpec.cpp b/nitrogen/generated/android/c++/JHybridCompressorSpec.cpp new file mode 100644 index 00000000..c7553bc1 --- /dev/null +++ b/nitrogen/generated/android/c++/JHybridCompressorSpec.cpp @@ -0,0 +1,293 @@ +/// +/// JHybridCompressorSpec.cpp +/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +/// https://github.com/mrousavy/nitro +/// Copyright © Marc Rousavy @ Margelo +/// + +#include "JHybridCompressorSpec.hpp" + +// Forward declaration of `VideoThumbnailResult` to properly resolve imports. +namespace margelo::nitro::compressor { struct VideoThumbnailResult; } + +#include +#include +#include +#include +#include +#include "VideoThumbnailResult.hpp" +#include "JVideoThumbnailResult.hpp" +#include +#include +#include "JFunc_void_double.hpp" +#include +#include "JFunc_void.hpp" +#include "JFunc_void_double_double.hpp" + +namespace margelo::nitro::compressor { + + std::shared_ptr JHybridCompressorSpec::JavaPart::getJHybridCompressorSpec() { + auto hybridObject = JHybridObject::JavaPart::getJHybridObject(); + auto castHybridObject = std::dynamic_pointer_cast(hybridObject); + if (castHybridObject == nullptr) [[unlikely]] { + throw std::runtime_error("Failed to downcast JHybridObject to JHybridCompressorSpec!"); + } + return castHybridObject; + } + + jni::local_ref JHybridCompressorSpec::CxxPart::initHybrid(jni::alias_ref jThis) { + return makeCxxInstance(jThis); + } + + std::shared_ptr JHybridCompressorSpec::CxxPart::createHybridObject(const jni::local_ref& javaPart) { + auto castJavaPart = jni::dynamic_ref_cast(javaPart); + if (castJavaPart == nullptr) [[unlikely]] { + throw std::runtime_error("Failed to cast JHybridObject::JavaPart to JHybridCompressorSpec::JavaPart!"); + } + return std::make_shared(castJavaPart); + } + + void JHybridCompressorSpec::CxxPart::registerNatives() { + registerHybrid({ + makeNativeMethod("initHybrid", JHybridCompressorSpec::CxxPart::initHybrid), + }); + } + + // Properties + + + // Methods + std::shared_ptr> JHybridCompressorSpec::image_compress(const std::string& imagePath, const std::shared_ptr& optionMap, const std::optional>& onDownloadProgress) { + static const auto method = _javaPart->javaClassStatic()->getMethod(jni::alias_ref /* imagePath */, jni::alias_ref /* optionMap */, jni::alias_ref /* onDownloadProgress */)>("image_compress_cxx"); + auto __result = method(_javaPart, jni::make_jstring(imagePath), JAnyMap::create(optionMap), onDownloadProgress.has_value() ? JFunc_void_double_cxx::fromCpp(onDownloadProgress.value()) : nullptr); + return [&]() { + auto __promise = Promise::create(); + __result->cthis()->addOnResolvedListener([=](const jni::alias_ref& __boxedResult) { + auto __result = jni::static_ref_cast(__boxedResult); + __promise->resolve(__result->toStdString()); + }); + __result->cthis()->addOnRejectedListener([=](const jni::alias_ref& __throwable) { + jni::JniException __jniError(__throwable); + __promise->reject(std::make_exception_ptr(__jniError)); + }); + return __promise; + }(); + } + std::shared_ptr>> JHybridCompressorSpec::getImageMetaData(const std::string& filePath) { + static const auto method = _javaPart->javaClassStatic()->getMethod(jni::alias_ref /* filePath */)>("getImageMetaData"); + auto __result = method(_javaPart, jni::make_jstring(filePath)); + return [&]() { + auto __promise = Promise>::create(); + __result->cthis()->addOnResolvedListener([=](const jni::alias_ref& __boxedResult) { + auto __result = jni::static_ref_cast(__boxedResult); + __promise->resolve(__result->cthis()->getMap()); + }); + __result->cthis()->addOnRejectedListener([=](const jni::alias_ref& __throwable) { + jni::JniException __jniError(__throwable); + __promise->reject(std::make_exception_ptr(__jniError)); + }); + return __promise; + }(); + } + std::shared_ptr> JHybridCompressorSpec::compress(const std::string& fileUrl, const std::shared_ptr& optionMap, const std::optional>& onProgress, const std::optional>& onDownloadProgress) { + static const auto method = _javaPart->javaClassStatic()->getMethod(jni::alias_ref /* fileUrl */, jni::alias_ref /* optionMap */, jni::alias_ref /* onProgress */, jni::alias_ref /* onDownloadProgress */)>("compress_cxx"); + auto __result = method(_javaPart, jni::make_jstring(fileUrl), JAnyMap::create(optionMap), onProgress.has_value() ? JFunc_void_double_cxx::fromCpp(onProgress.value()) : nullptr, onDownloadProgress.has_value() ? JFunc_void_double_cxx::fromCpp(onDownloadProgress.value()) : nullptr); + return [&]() { + auto __promise = Promise::create(); + __result->cthis()->addOnResolvedListener([=](const jni::alias_ref& __boxedResult) { + auto __result = jni::static_ref_cast(__boxedResult); + __promise->resolve(__result->toStdString()); + }); + __result->cthis()->addOnRejectedListener([=](const jni::alias_ref& __throwable) { + jni::JniException __jniError(__throwable); + __promise->reject(std::make_exception_ptr(__jniError)); + }); + return __promise; + }(); + } + void JHybridCompressorSpec::cancelCompression(const std::string& uuid) { + static const auto method = _javaPart->javaClassStatic()->getMethod /* uuid */)>("cancelCompression"); + method(_javaPart, jni::make_jstring(uuid)); + } + std::shared_ptr>> JHybridCompressorSpec::getVideoMetaData(const std::string& filePath) { + static const auto method = _javaPart->javaClassStatic()->getMethod(jni::alias_ref /* filePath */)>("getVideoMetaData"); + auto __result = method(_javaPart, jni::make_jstring(filePath)); + return [&]() { + auto __promise = Promise>::create(); + __result->cthis()->addOnResolvedListener([=](const jni::alias_ref& __boxedResult) { + auto __result = jni::static_ref_cast(__boxedResult); + __promise->resolve(__result->cthis()->getMap()); + }); + __result->cthis()->addOnRejectedListener([=](const jni::alias_ref& __throwable) { + jni::JniException __jniError(__throwable); + __promise->reject(std::make_exception_ptr(__jniError)); + }); + return __promise; + }(); + } + std::shared_ptr> JHybridCompressorSpec::activateBackgroundTask(const std::shared_ptr& options, const std::optional>& onExpired) { + static const auto method = _javaPart->javaClassStatic()->getMethod(jni::alias_ref /* options */, jni::alias_ref /* onExpired */)>("activateBackgroundTask_cxx"); + auto __result = method(_javaPart, JAnyMap::create(options), onExpired.has_value() ? JFunc_void_cxx::fromCpp(onExpired.value()) : nullptr); + return [&]() { + auto __promise = Promise::create(); + __result->cthis()->addOnResolvedListener([=](const jni::alias_ref& __boxedResult) { + auto __result = jni::static_ref_cast(__boxedResult); + __promise->resolve(__result->toStdString()); + }); + __result->cthis()->addOnRejectedListener([=](const jni::alias_ref& __throwable) { + jni::JniException __jniError(__throwable); + __promise->reject(std::make_exception_ptr(__jniError)); + }); + return __promise; + }(); + } + std::shared_ptr> JHybridCompressorSpec::deactivateBackgroundTask(const std::shared_ptr& options) { + static const auto method = _javaPart->javaClassStatic()->getMethod(jni::alias_ref /* options */)>("deactivateBackgroundTask"); + auto __result = method(_javaPart, JAnyMap::create(options)); + return [&]() { + auto __promise = Promise::create(); + __result->cthis()->addOnResolvedListener([=](const jni::alias_ref& __boxedResult) { + auto __result = jni::static_ref_cast(__boxedResult); + __promise->resolve(__result->toStdString()); + }); + __result->cthis()->addOnRejectedListener([=](const jni::alias_ref& __throwable) { + jni::JniException __jniError(__throwable); + __promise->reject(std::make_exception_ptr(__jniError)); + }); + return __promise; + }(); + } + std::shared_ptr> JHybridCompressorSpec::compress_audio(const std::string& fileUrl, const std::shared_ptr& optionMap) { + static const auto method = _javaPart->javaClassStatic()->getMethod(jni::alias_ref /* fileUrl */, jni::alias_ref /* optionMap */)>("compress_audio"); + auto __result = method(_javaPart, jni::make_jstring(fileUrl), JAnyMap::create(optionMap)); + return [&]() { + auto __promise = Promise::create(); + __result->cthis()->addOnResolvedListener([=](const jni::alias_ref& __boxedResult) { + auto __result = jni::static_ref_cast(__boxedResult); + __promise->resolve(__result->toStdString()); + }); + __result->cthis()->addOnRejectedListener([=](const jni::alias_ref& __throwable) { + jni::JniException __jniError(__throwable); + __promise->reject(std::make_exception_ptr(__jniError)); + }); + return __promise; + }(); + } + std::shared_ptr>> JHybridCompressorSpec::upload(const std::string& fileUrl, const std::shared_ptr& options, const std::optional>& onProgress) { + static const auto method = _javaPart->javaClassStatic()->getMethod(jni::alias_ref /* fileUrl */, jni::alias_ref /* options */, jni::alias_ref /* onProgress */)>("upload_cxx"); + auto __result = method(_javaPart, jni::make_jstring(fileUrl), JAnyMap::create(options), onProgress.has_value() ? JFunc_void_double_double_cxx::fromCpp(onProgress.value()) : nullptr); + return [&]() { + auto __promise = Promise>::create(); + __result->cthis()->addOnResolvedListener([=](const jni::alias_ref& __boxedResult) { + auto __result = jni::static_ref_cast(__boxedResult); + __promise->resolve(__result->cthis()->getMap()); + }); + __result->cthis()->addOnRejectedListener([=](const jni::alias_ref& __throwable) { + jni::JniException __jniError(__throwable); + __promise->reject(std::make_exception_ptr(__jniError)); + }); + return __promise; + }(); + } + void JHybridCompressorSpec::cancelUpload(const std::string& uuid, bool shouldCancelAll) { + static const auto method = _javaPart->javaClassStatic()->getMethod /* uuid */, jboolean /* shouldCancelAll */)>("cancelUpload"); + method(_javaPart, jni::make_jstring(uuid), shouldCancelAll); + } + std::shared_ptr> JHybridCompressorSpec::download(const std::string& fileUrl, const std::shared_ptr& options, const std::optional>& onProgress) { + static const auto method = _javaPart->javaClassStatic()->getMethod(jni::alias_ref /* fileUrl */, jni::alias_ref /* options */, jni::alias_ref /* onProgress */)>("download_cxx"); + auto __result = method(_javaPart, jni::make_jstring(fileUrl), JAnyMap::create(options), onProgress.has_value() ? JFunc_void_double_cxx::fromCpp(onProgress.value()) : nullptr); + return [&]() { + auto __promise = Promise::create(); + __result->cthis()->addOnResolvedListener([=](const jni::alias_ref& __boxedResult) { + auto __result = jni::static_ref_cast(__boxedResult); + __promise->resolve(__result->toStdString()); + }); + __result->cthis()->addOnRejectedListener([=](const jni::alias_ref& __throwable) { + jni::JniException __jniError(__throwable); + __promise->reject(std::make_exception_ptr(__jniError)); + }); + return __promise; + }(); + } + std::shared_ptr> JHybridCompressorSpec::generateFilePath(const std::string& fileExtension) { + static const auto method = _javaPart->javaClassStatic()->getMethod(jni::alias_ref /* fileExtension */)>("generateFilePath"); + auto __result = method(_javaPart, jni::make_jstring(fileExtension)); + return [&]() { + auto __promise = Promise::create(); + __result->cthis()->addOnResolvedListener([=](const jni::alias_ref& __boxedResult) { + auto __result = jni::static_ref_cast(__boxedResult); + __promise->resolve(__result->toStdString()); + }); + __result->cthis()->addOnRejectedListener([=](const jni::alias_ref& __throwable) { + jni::JniException __jniError(__throwable); + __promise->reject(std::make_exception_ptr(__jniError)); + }); + return __promise; + }(); + } + std::shared_ptr> JHybridCompressorSpec::getRealPath(const std::string& path, const std::string& type) { + static const auto method = _javaPart->javaClassStatic()->getMethod(jni::alias_ref /* path */, jni::alias_ref /* type */)>("getRealPath"); + auto __result = method(_javaPart, jni::make_jstring(path), jni::make_jstring(type)); + return [&]() { + auto __promise = Promise::create(); + __result->cthis()->addOnResolvedListener([=](const jni::alias_ref& __boxedResult) { + auto __result = jni::static_ref_cast(__boxedResult); + __promise->resolve(__result->toStdString()); + }); + __result->cthis()->addOnRejectedListener([=](const jni::alias_ref& __throwable) { + jni::JniException __jniError(__throwable); + __promise->reject(std::make_exception_ptr(__jniError)); + }); + return __promise; + }(); + } + std::shared_ptr> JHybridCompressorSpec::getFileSize(const std::string& filePath) { + static const auto method = _javaPart->javaClassStatic()->getMethod(jni::alias_ref /* filePath */)>("getFileSize"); + auto __result = method(_javaPart, jni::make_jstring(filePath)); + return [&]() { + auto __promise = Promise::create(); + __result->cthis()->addOnResolvedListener([=](const jni::alias_ref& __boxedResult) { + auto __result = jni::static_ref_cast(__boxedResult); + __promise->resolve(__result->toStdString()); + }); + __result->cthis()->addOnRejectedListener([=](const jni::alias_ref& __throwable) { + jni::JniException __jniError(__throwable); + __promise->reject(std::make_exception_ptr(__jniError)); + }); + return __promise; + }(); + } + std::shared_ptr> JHybridCompressorSpec::createVideoThumbnail(const std::string& fileUrl, const std::shared_ptr& options) { + static const auto method = _javaPart->javaClassStatic()->getMethod(jni::alias_ref /* fileUrl */, jni::alias_ref /* options */)>("createVideoThumbnail"); + auto __result = method(_javaPart, jni::make_jstring(fileUrl), JAnyMap::create(options)); + return [&]() { + auto __promise = Promise::create(); + __result->cthis()->addOnResolvedListener([=](const jni::alias_ref& __boxedResult) { + auto __result = jni::static_ref_cast(__boxedResult); + __promise->resolve(__result->toCpp()); + }); + __result->cthis()->addOnRejectedListener([=](const jni::alias_ref& __throwable) { + jni::JniException __jniError(__throwable); + __promise->reject(std::make_exception_ptr(__jniError)); + }); + return __promise; + }(); + } + std::shared_ptr> JHybridCompressorSpec::clearCache(const std::optional& cacheDir) { + static const auto method = _javaPart->javaClassStatic()->getMethod(jni::alias_ref /* cacheDir */)>("clearCache"); + auto __result = method(_javaPart, cacheDir.has_value() ? jni::make_jstring(cacheDir.value()) : nullptr); + return [&]() { + auto __promise = Promise::create(); + __result->cthis()->addOnResolvedListener([=](const jni::alias_ref& __boxedResult) { + auto __result = jni::static_ref_cast(__boxedResult); + __promise->resolve(__result->toStdString()); + }); + __result->cthis()->addOnRejectedListener([=](const jni::alias_ref& __throwable) { + jni::JniException __jniError(__throwable); + __promise->reject(std::make_exception_ptr(__jniError)); + }); + return __promise; + }(); + } + +} // namespace margelo::nitro::compressor diff --git a/nitrogen/generated/android/c++/JHybridCompressorSpec.hpp b/nitrogen/generated/android/c++/JHybridCompressorSpec.hpp new file mode 100644 index 00000000..edeb1c31 --- /dev/null +++ b/nitrogen/generated/android/c++/JHybridCompressorSpec.hpp @@ -0,0 +1,78 @@ +/// +/// HybridCompressorSpec.hpp +/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +/// https://github.com/mrousavy/nitro +/// Copyright © Marc Rousavy @ Margelo +/// + +#pragma once + +#include +#include +#include "HybridCompressorSpec.hpp" + + + + +namespace margelo::nitro::compressor { + + using namespace facebook; + + class JHybridCompressorSpec: public virtual HybridCompressorSpec, public virtual JHybridObject { + public: + struct JavaPart: public jni::JavaClass { + static constexpr auto kJavaDescriptor = "Lcom/margelo/nitro/compressor/HybridCompressorSpec;"; + std::shared_ptr getJHybridCompressorSpec(); + }; + struct CxxPart: public jni::HybridClass { + static constexpr auto kJavaDescriptor = "Lcom/margelo/nitro/compressor/HybridCompressorSpec$CxxPart;"; + static jni::local_ref initHybrid(jni::alias_ref jThis); + static void registerNatives(); + using HybridBase::HybridBase; + protected: + std::shared_ptr createHybridObject(const jni::local_ref& javaPart) override; + }; + + public: + explicit JHybridCompressorSpec(const jni::local_ref& javaPart): + HybridObject(HybridCompressorSpec::TAG), + JHybridObject(javaPart), + _javaPart(jni::make_global(javaPart)) {} + ~JHybridCompressorSpec() override { + // Hermes GC can destroy JS objects on a non-JNI Thread. + jni::ThreadScope::WithClassLoader([&] { _javaPart.reset(); }); + } + + public: + inline const jni::global_ref& getJavaPart() const noexcept { + return _javaPart; + } + + public: + // Properties + + + public: + // Methods + std::shared_ptr> image_compress(const std::string& imagePath, const std::shared_ptr& optionMap, const std::optional>& onDownloadProgress) override; + std::shared_ptr>> getImageMetaData(const std::string& filePath) override; + std::shared_ptr> compress(const std::string& fileUrl, const std::shared_ptr& optionMap, const std::optional>& onProgress, const std::optional>& onDownloadProgress) override; + void cancelCompression(const std::string& uuid) override; + std::shared_ptr>> getVideoMetaData(const std::string& filePath) override; + std::shared_ptr> activateBackgroundTask(const std::shared_ptr& options, const std::optional>& onExpired) override; + std::shared_ptr> deactivateBackgroundTask(const std::shared_ptr& options) override; + std::shared_ptr> compress_audio(const std::string& fileUrl, const std::shared_ptr& optionMap) override; + std::shared_ptr>> upload(const std::string& fileUrl, const std::shared_ptr& options, const std::optional>& onProgress) override; + void cancelUpload(const std::string& uuid, bool shouldCancelAll) override; + std::shared_ptr> download(const std::string& fileUrl, const std::shared_ptr& options, const std::optional>& onProgress) override; + std::shared_ptr> generateFilePath(const std::string& fileExtension) override; + std::shared_ptr> getRealPath(const std::string& path, const std::string& type) override; + std::shared_ptr> getFileSize(const std::string& filePath) override; + std::shared_ptr> createVideoThumbnail(const std::string& fileUrl, const std::shared_ptr& options) override; + std::shared_ptr> clearCache(const std::optional& cacheDir) override; + + private: + jni::global_ref _javaPart; + }; + +} // namespace margelo::nitro::compressor diff --git a/nitrogen/generated/android/c++/JVideoThumbnailResult.hpp b/nitrogen/generated/android/c++/JVideoThumbnailResult.hpp new file mode 100644 index 00000000..b9cbea90 --- /dev/null +++ b/nitrogen/generated/android/c++/JVideoThumbnailResult.hpp @@ -0,0 +1,73 @@ +/// +/// JVideoThumbnailResult.hpp +/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +/// https://github.com/mrousavy/nitro +/// Copyright © Marc Rousavy @ Margelo +/// + +#pragma once + +#include +#include "VideoThumbnailResult.hpp" + +#include + +namespace margelo::nitro::compressor { + + using namespace facebook; + + /** + * The C++ JNI bridge between the C++ struct "VideoThumbnailResult" and the the Kotlin data class "VideoThumbnailResult". + */ + struct JVideoThumbnailResult final: public jni::JavaClass { + public: + static constexpr auto kJavaDescriptor = "Lcom/margelo/nitro/compressor/VideoThumbnailResult;"; + + public: + /** + * Convert this Java/Kotlin-based struct to the C++ struct VideoThumbnailResult by copying all values to C++. + */ + [[maybe_unused]] + [[nodiscard]] + VideoThumbnailResult toCpp() const { + static const auto clazz = javaClassStatic(); + static const auto fieldPath = clazz->getField("path"); + jni::local_ref path = this->getFieldValue(fieldPath); + static const auto fieldSize = clazz->getField("size"); + double size = this->getFieldValue(fieldSize); + static const auto fieldMime = clazz->getField("mime"); + jni::local_ref mime = this->getFieldValue(fieldMime); + static const auto fieldWidth = clazz->getField("width"); + double width = this->getFieldValue(fieldWidth); + static const auto fieldHeight = clazz->getField("height"); + double height = this->getFieldValue(fieldHeight); + return VideoThumbnailResult( + path->toStdString(), + size, + mime->toStdString(), + width, + height + ); + } + + public: + /** + * Create a Java/Kotlin-based struct by copying all values from the given C++ struct to Java. + */ + [[maybe_unused]] + static jni::local_ref fromCpp(const VideoThumbnailResult& value) { + using JSignature = JVideoThumbnailResult(jni::alias_ref, double, jni::alias_ref, double, double); + static const auto clazz = javaClassStatic(); + static const auto create = clazz->getStaticMethod("fromCpp"); + return create( + clazz, + jni::make_jstring(value.path), + value.size, + jni::make_jstring(value.mime), + value.width, + value.height + ); + } + }; + +} // namespace margelo::nitro::compressor diff --git a/nitrogen/generated/android/kotlin/com/margelo/nitro/compressor/Func_void.kt b/nitrogen/generated/android/kotlin/com/margelo/nitro/compressor/Func_void.kt new file mode 100644 index 00000000..3b733e87 --- /dev/null +++ b/nitrogen/generated/android/kotlin/com/margelo/nitro/compressor/Func_void.kt @@ -0,0 +1,80 @@ +/// +/// Func_void.kt +/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +/// https://github.com/mrousavy/nitro +/// Copyright © Marc Rousavy @ Margelo +/// + +package com.margelo.nitro.compressor + +import androidx.annotation.Keep +import com.facebook.jni.HybridData +import com.facebook.proguard.annotations.DoNotStrip +import dalvik.annotation.optimization.FastNative + + +/** + * Represents the JavaScript callback `() => void`. + * This can be either implemented in C++ (in which case it might be a callback coming from JS), + * or in Kotlin/Java (in which case it is a native callback). + */ +@DoNotStrip +@Keep +@Suppress("ClassName", "RedundantUnitReturnType") +fun interface Func_void: () -> Unit { + /** + * Call the given JS callback. + * @throws Throwable if the JS function itself throws an error, or if the JS function/runtime has already been deleted. + */ + @DoNotStrip + @Keep + override fun invoke(): Unit +} + +/** + * Represents the JavaScript callback `() => void`. + * This is implemented in C++, via a `std::function<...>`. + * The callback might be coming from JS. + */ +@DoNotStrip +@Keep +@Suppress( + "KotlinJniMissingFunction", "unused", + "RedundantSuppression", "RedundantUnitReturnType", "FunctionName", + "ConvertSecondaryConstructorToPrimary", "ClassName", "LocalVariableName", +) +class Func_void_cxx: Func_void { + @DoNotStrip + @Keep + private val mHybridData: HybridData + + @DoNotStrip + @Keep + private constructor(hybridData: HybridData) { + mHybridData = hybridData + } + + @DoNotStrip + @Keep + override fun invoke(): Unit + = invoke_cxx() + + @FastNative + private external fun invoke_cxx(): Unit +} + +/** + * Represents the JavaScript callback `() => void`. + * This is implemented in Java/Kotlin, via a `() -> Unit`. + * The callback is always coming from native. + */ +@DoNotStrip +@Keep +@Suppress("ClassName", "RedundantUnitReturnType", "unused") +class Func_void_java(private val function: () -> Unit): Func_void { + @DoNotStrip + @Keep + override fun invoke(): Unit { + return this.function() + } +} diff --git a/nitrogen/generated/android/kotlin/com/margelo/nitro/compressor/Func_void_double.kt b/nitrogen/generated/android/kotlin/com/margelo/nitro/compressor/Func_void_double.kt new file mode 100644 index 00000000..7699a88f --- /dev/null +++ b/nitrogen/generated/android/kotlin/com/margelo/nitro/compressor/Func_void_double.kt @@ -0,0 +1,80 @@ +/// +/// Func_void_double.kt +/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +/// https://github.com/mrousavy/nitro +/// Copyright © Marc Rousavy @ Margelo +/// + +package com.margelo.nitro.compressor + +import androidx.annotation.Keep +import com.facebook.jni.HybridData +import com.facebook.proguard.annotations.DoNotStrip +import dalvik.annotation.optimization.FastNative + + +/** + * Represents the JavaScript callback `(progress: number) => void`. + * This can be either implemented in C++ (in which case it might be a callback coming from JS), + * or in Kotlin/Java (in which case it is a native callback). + */ +@DoNotStrip +@Keep +@Suppress("ClassName", "RedundantUnitReturnType") +fun interface Func_void_double: (Double) -> Unit { + /** + * Call the given JS callback. + * @throws Throwable if the JS function itself throws an error, or if the JS function/runtime has already been deleted. + */ + @DoNotStrip + @Keep + override fun invoke(progress: Double): Unit +} + +/** + * Represents the JavaScript callback `(progress: number) => void`. + * This is implemented in C++, via a `std::function<...>`. + * The callback might be coming from JS. + */ +@DoNotStrip +@Keep +@Suppress( + "KotlinJniMissingFunction", "unused", + "RedundantSuppression", "RedundantUnitReturnType", "FunctionName", + "ConvertSecondaryConstructorToPrimary", "ClassName", "LocalVariableName", +) +class Func_void_double_cxx: Func_void_double { + @DoNotStrip + @Keep + private val mHybridData: HybridData + + @DoNotStrip + @Keep + private constructor(hybridData: HybridData) { + mHybridData = hybridData + } + + @DoNotStrip + @Keep + override fun invoke(progress: Double): Unit + = invoke_cxx(progress) + + @FastNative + private external fun invoke_cxx(progress: Double): Unit +} + +/** + * Represents the JavaScript callback `(progress: number) => void`. + * This is implemented in Java/Kotlin, via a `(Double) -> Unit`. + * The callback is always coming from native. + */ +@DoNotStrip +@Keep +@Suppress("ClassName", "RedundantUnitReturnType", "unused") +class Func_void_double_java(private val function: (Double) -> Unit): Func_void_double { + @DoNotStrip + @Keep + override fun invoke(progress: Double): Unit { + return this.function(progress) + } +} diff --git a/nitrogen/generated/android/kotlin/com/margelo/nitro/compressor/Func_void_double_double.kt b/nitrogen/generated/android/kotlin/com/margelo/nitro/compressor/Func_void_double_double.kt new file mode 100644 index 00000000..d6966322 --- /dev/null +++ b/nitrogen/generated/android/kotlin/com/margelo/nitro/compressor/Func_void_double_double.kt @@ -0,0 +1,80 @@ +/// +/// Func_void_double_double.kt +/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +/// https://github.com/mrousavy/nitro +/// Copyright © Marc Rousavy @ Margelo +/// + +package com.margelo.nitro.compressor + +import androidx.annotation.Keep +import com.facebook.jni.HybridData +import com.facebook.proguard.annotations.DoNotStrip +import dalvik.annotation.optimization.FastNative + + +/** + * Represents the JavaScript callback `(written: number, total: number) => void`. + * This can be either implemented in C++ (in which case it might be a callback coming from JS), + * or in Kotlin/Java (in which case it is a native callback). + */ +@DoNotStrip +@Keep +@Suppress("ClassName", "RedundantUnitReturnType") +fun interface Func_void_double_double: (Double, Double) -> Unit { + /** + * Call the given JS callback. + * @throws Throwable if the JS function itself throws an error, or if the JS function/runtime has already been deleted. + */ + @DoNotStrip + @Keep + override fun invoke(written: Double, total: Double): Unit +} + +/** + * Represents the JavaScript callback `(written: number, total: number) => void`. + * This is implemented in C++, via a `std::function<...>`. + * The callback might be coming from JS. + */ +@DoNotStrip +@Keep +@Suppress( + "KotlinJniMissingFunction", "unused", + "RedundantSuppression", "RedundantUnitReturnType", "FunctionName", + "ConvertSecondaryConstructorToPrimary", "ClassName", "LocalVariableName", +) +class Func_void_double_double_cxx: Func_void_double_double { + @DoNotStrip + @Keep + private val mHybridData: HybridData + + @DoNotStrip + @Keep + private constructor(hybridData: HybridData) { + mHybridData = hybridData + } + + @DoNotStrip + @Keep + override fun invoke(written: Double, total: Double): Unit + = invoke_cxx(written,total) + + @FastNative + private external fun invoke_cxx(written: Double, total: Double): Unit +} + +/** + * Represents the JavaScript callback `(written: number, total: number) => void`. + * This is implemented in Java/Kotlin, via a `(Double, Double) -> Unit`. + * The callback is always coming from native. + */ +@DoNotStrip +@Keep +@Suppress("ClassName", "RedundantUnitReturnType", "unused") +class Func_void_double_double_java(private val function: (Double, Double) -> Unit): Func_void_double_double { + @DoNotStrip + @Keep + override fun invoke(written: Double, total: Double): Unit { + return this.function(written, total) + } +} diff --git a/nitrogen/generated/android/kotlin/com/margelo/nitro/compressor/HybridCompressorSpec.kt b/nitrogen/generated/android/kotlin/com/margelo/nitro/compressor/HybridCompressorSpec.kt new file mode 100644 index 00000000..954456a3 --- /dev/null +++ b/nitrogen/generated/android/kotlin/com/margelo/nitro/compressor/HybridCompressorSpec.kt @@ -0,0 +1,141 @@ +/// +/// HybridCompressorSpec.kt +/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +/// https://github.com/mrousavy/nitro +/// Copyright © Marc Rousavy @ Margelo +/// + +package com.margelo.nitro.compressor + +import androidx.annotation.Keep +import com.facebook.jni.HybridData +import com.facebook.proguard.annotations.DoNotStrip +import com.margelo.nitro.core.Promise +import com.margelo.nitro.core.AnyMap +import com.margelo.nitro.core.HybridObject + +/** + * A Kotlin class representing the Compressor HybridObject. + * Implement this abstract class to create Kotlin-based instances of Compressor. + */ +@DoNotStrip +@Keep +@Suppress( + "KotlinJniMissingFunction", "unused", + "RedundantSuppression", "RedundantUnitReturnType", "SimpleRedundantLet", + "LocalVariableName", "PropertyName", "PrivatePropertyName", "FunctionName" +) +abstract class HybridCompressorSpec: HybridObject() { + // Properties + + + // Methods + abstract fun image_compress(imagePath: String, optionMap: AnyMap, onDownloadProgress: ((progress: Double) -> Unit)?): Promise + + @DoNotStrip + @Keep + private fun image_compress_cxx(imagePath: String, optionMap: AnyMap, onDownloadProgress: Func_void_double?): Promise { + val __result = image_compress(imagePath, optionMap, onDownloadProgress?.let { it }) + return __result + } + + @DoNotStrip + @Keep + abstract fun getImageMetaData(filePath: String): Promise + + abstract fun compress(fileUrl: String, optionMap: AnyMap, onProgress: ((progress: Double) -> Unit)?, onDownloadProgress: ((progress: Double) -> Unit)?): Promise + + @DoNotStrip + @Keep + private fun compress_cxx(fileUrl: String, optionMap: AnyMap, onProgress: Func_void_double?, onDownloadProgress: Func_void_double?): Promise { + val __result = compress(fileUrl, optionMap, onProgress?.let { it }, onDownloadProgress?.let { it }) + return __result + } + + @DoNotStrip + @Keep + abstract fun cancelCompression(uuid: String): Unit + + @DoNotStrip + @Keep + abstract fun getVideoMetaData(filePath: String): Promise + + abstract fun activateBackgroundTask(options: AnyMap, onExpired: (() -> Unit)?): Promise + + @DoNotStrip + @Keep + private fun activateBackgroundTask_cxx(options: AnyMap, onExpired: Func_void?): Promise { + val __result = activateBackgroundTask(options, onExpired?.let { it }) + return __result + } + + @DoNotStrip + @Keep + abstract fun deactivateBackgroundTask(options: AnyMap): Promise + + @DoNotStrip + @Keep + abstract fun compress_audio(fileUrl: String, optionMap: AnyMap): Promise + + abstract fun upload(fileUrl: String, options: AnyMap, onProgress: ((written: Double, total: Double) -> Unit)?): Promise + + @DoNotStrip + @Keep + private fun upload_cxx(fileUrl: String, options: AnyMap, onProgress: Func_void_double_double?): Promise { + val __result = upload(fileUrl, options, onProgress?.let { it }) + return __result + } + + @DoNotStrip + @Keep + abstract fun cancelUpload(uuid: String, shouldCancelAll: Boolean): Unit + + abstract fun download(fileUrl: String, options: AnyMap, onProgress: ((progress: Double) -> Unit)?): Promise + + @DoNotStrip + @Keep + private fun download_cxx(fileUrl: String, options: AnyMap, onProgress: Func_void_double?): Promise { + val __result = download(fileUrl, options, onProgress?.let { it }) + return __result + } + + @DoNotStrip + @Keep + abstract fun generateFilePath(fileExtension: String): Promise + + @DoNotStrip + @Keep + abstract fun getRealPath(path: String, type: String): Promise + + @DoNotStrip + @Keep + abstract fun getFileSize(filePath: String): Promise + + @DoNotStrip + @Keep + abstract fun createVideoThumbnail(fileUrl: String, options: AnyMap): Promise + + @DoNotStrip + @Keep + abstract fun clearCache(cacheDir: String?): Promise + + // Default implementation of `HybridObject.toString()` + override fun toString(): String { + return "[HybridObject Compressor]" + } + + // C++ backing class + @DoNotStrip + @Keep + protected open class CxxPart(javaPart: HybridCompressorSpec): HybridObject.CxxPart(javaPart) { + // C++ JHybridCompressorSpec::CxxPart::initHybrid(...) + external override fun initHybrid(): HybridData + } + override fun createCxxPart(): CxxPart { + return CxxPart(this) + } + + companion object { + protected const val TAG = "HybridCompressorSpec" + } +} diff --git a/nitrogen/generated/android/kotlin/com/margelo/nitro/compressor/NitroCompressorOnLoad.kt b/nitrogen/generated/android/kotlin/com/margelo/nitro/compressor/NitroCompressorOnLoad.kt new file mode 100644 index 00000000..04251646 --- /dev/null +++ b/nitrogen/generated/android/kotlin/com/margelo/nitro/compressor/NitroCompressorOnLoad.kt @@ -0,0 +1,35 @@ +/// +/// NitroCompressorOnLoad.kt +/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +/// https://github.com/mrousavy/nitro +/// Copyright © Marc Rousavy @ Margelo +/// + +package com.margelo.nitro.compressor + +import android.util.Log + +internal class NitroCompressorOnLoad { + companion object { + private const val TAG = "NitroCompressorOnLoad" + private var didLoad = false + /** + * Initializes the native part of "NitroCompressor". + * This method is idempotent and can be called more than once. + */ + @JvmStatic + fun initializeNative() { + if (didLoad) return + try { + Log.i(TAG, "Loading NitroCompressor C++ library...") + System.loadLibrary("NitroCompressor") + Log.i(TAG, "Successfully loaded NitroCompressor C++ library!") + didLoad = true + } catch (e: Error) { + Log.e(TAG, "Failed to load NitroCompressor C++ library! Is it properly installed and linked? " + + "Is the name correct? (see `CMakeLists.txt`, at `add_library(...)`)", e) + throw e + } + } + } +} diff --git a/nitrogen/generated/android/kotlin/com/margelo/nitro/compressor/VideoThumbnailResult.kt b/nitrogen/generated/android/kotlin/com/margelo/nitro/compressor/VideoThumbnailResult.kt new file mode 100644 index 00000000..54a09434 --- /dev/null +++ b/nitrogen/generated/android/kotlin/com/margelo/nitro/compressor/VideoThumbnailResult.kt @@ -0,0 +1,71 @@ +/// +/// VideoThumbnailResult.kt +/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +/// https://github.com/mrousavy/nitro +/// Copyright © Marc Rousavy @ Margelo +/// + +package com.margelo.nitro.compressor + +import androidx.annotation.Keep +import com.facebook.proguard.annotations.DoNotStrip +import java.util.Objects + + +/** + * Represents the JavaScript object/struct "VideoThumbnailResult". + */ +@DoNotStrip +@Keep +data class VideoThumbnailResult( + @DoNotStrip + @Keep + val path: String, + @DoNotStrip + @Keep + val size: Double, + @DoNotStrip + @Keep + val mime: String, + @DoNotStrip + @Keep + val width: Double, + @DoNotStrip + @Keep + val height: Double +) { + /* primary constructor */ + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is VideoThumbnailResult) return false + return Objects.deepEquals(this.path, other.path) + && Objects.deepEquals(this.size, other.size) + && Objects.deepEquals(this.mime, other.mime) + && Objects.deepEquals(this.width, other.width) + && Objects.deepEquals(this.height, other.height) + } + + override fun hashCode(): Int { + return arrayOf( + path, + size, + mime, + width, + height + ).contentDeepHashCode() + } + + companion object { + /** + * Constructor called from C++ + */ + @DoNotStrip + @Keep + @Suppress("unused") + @JvmStatic + private fun fromCpp(path: String, size: Double, mime: String, width: Double, height: Double): VideoThumbnailResult { + return VideoThumbnailResult(path, size, mime, width, height) + } + } +} diff --git a/nitrogen/generated/ios/c++/HybridCompressorSpecSwift.cpp b/nitrogen/generated/ios/c++/HybridCompressorSpecSwift.cpp new file mode 100644 index 00000000..41763435 --- /dev/null +++ b/nitrogen/generated/ios/c++/HybridCompressorSpecSwift.cpp @@ -0,0 +1,11 @@ +/// +/// HybridCompressorSpecSwift.cpp +/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +/// https://github.com/mrousavy/nitro +/// Copyright © Marc Rousavy @ Margelo +/// + +#include "HybridCompressorSpecSwift.hpp" + +namespace margelo::nitro::compressor { +} // namespace margelo::nitro::compressor diff --git a/nitrogen/generated/ios/c++/HybridCompressorSpecSwift.hpp b/nitrogen/generated/ios/c++/HybridCompressorSpecSwift.hpp new file mode 100644 index 00000000..3d0140b4 --- /dev/null +++ b/nitrogen/generated/ios/c++/HybridCompressorSpecSwift.hpp @@ -0,0 +1,204 @@ +/// +/// HybridCompressorSpecSwift.hpp +/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +/// https://github.com/mrousavy/nitro +/// Copyright © Marc Rousavy @ Margelo +/// + +#pragma once + +#include "HybridCompressorSpec.hpp" + +// Forward declaration of `HybridCompressorSpec_cxx` to properly resolve imports. +namespace react_native_compressor { class HybridCompressorSpec_cxx; } + +// Forward declaration of `VideoThumbnailResult` to properly resolve imports. +namespace margelo::nitro::compressor { struct VideoThumbnailResult; } + +#include +#include +#include +#include +#include +#include "VideoThumbnailResult.hpp" + +#include "react_native_compressor-Swift-Cxx-Umbrella.hpp" + +namespace margelo::nitro::compressor { + + /** + * The C++ part of HybridCompressorSpec_cxx.swift. + * + * HybridCompressorSpecSwift (C++) accesses HybridCompressorSpec_cxx (Swift), and might + * contain some additional bridging code for C++ <> Swift interop. + * + * Since this obviously introduces an overhead, I hope at some point in + * the future, HybridCompressorSpec_cxx can directly inherit from the C++ class HybridCompressorSpec + * to simplify the whole structure and memory management. + */ + class HybridCompressorSpecSwift: public virtual HybridCompressorSpec { + public: + // Constructor from a Swift instance + explicit HybridCompressorSpecSwift(const react_native_compressor::HybridCompressorSpec_cxx& swiftPart): + HybridObject(HybridCompressorSpec::TAG), + _swiftPart(swiftPart) { } + + public: + // Get the Swift part + inline react_native_compressor::HybridCompressorSpec_cxx& getSwiftPart() noexcept { + return _swiftPart; + } + + public: + inline size_t getExternalMemorySize() noexcept override { + return _swiftPart.getMemorySize(); + } + bool equals(const std::shared_ptr& other) override { + if (auto otherCast = std::dynamic_pointer_cast(other)) { + return _swiftPart.equals(otherCast->_swiftPart); + } + return false; + } + void dispose() noexcept override { + _swiftPart.dispose(); + } + std::string toString() override { + return _swiftPart.toString(); + } + + public: + // Properties + + + public: + // Methods + inline std::shared_ptr> image_compress(const std::string& imagePath, const std::shared_ptr& optionMap, const std::optional>& onDownloadProgress) override { + auto __result = _swiftPart.image_compress(imagePath, optionMap, onDownloadProgress); + if (__result.hasError()) [[unlikely]] { + std::rethrow_exception(__result.error()); + } + auto __value = std::move(__result.value()); + return __value; + } + inline std::shared_ptr>> getImageMetaData(const std::string& filePath) override { + auto __result = _swiftPart.getImageMetaData(filePath); + if (__result.hasError()) [[unlikely]] { + std::rethrow_exception(__result.error()); + } + auto __value = std::move(__result.value()); + return __value; + } + inline std::shared_ptr> compress(const std::string& fileUrl, const std::shared_ptr& optionMap, const std::optional>& onProgress, const std::optional>& onDownloadProgress) override { + auto __result = _swiftPart.compress(fileUrl, optionMap, onProgress, onDownloadProgress); + if (__result.hasError()) [[unlikely]] { + std::rethrow_exception(__result.error()); + } + auto __value = std::move(__result.value()); + return __value; + } + inline void cancelCompression(const std::string& uuid) override { + auto __result = _swiftPart.cancelCompression(uuid); + if (__result.hasError()) [[unlikely]] { + std::rethrow_exception(__result.error()); + } + } + inline std::shared_ptr>> getVideoMetaData(const std::string& filePath) override { + auto __result = _swiftPart.getVideoMetaData(filePath); + if (__result.hasError()) [[unlikely]] { + std::rethrow_exception(__result.error()); + } + auto __value = std::move(__result.value()); + return __value; + } + inline std::shared_ptr> activateBackgroundTask(const std::shared_ptr& options, const std::optional>& onExpired) override { + auto __result = _swiftPart.activateBackgroundTask(options, onExpired); + if (__result.hasError()) [[unlikely]] { + std::rethrow_exception(__result.error()); + } + auto __value = std::move(__result.value()); + return __value; + } + inline std::shared_ptr> deactivateBackgroundTask(const std::shared_ptr& options) override { + auto __result = _swiftPart.deactivateBackgroundTask(options); + if (__result.hasError()) [[unlikely]] { + std::rethrow_exception(__result.error()); + } + auto __value = std::move(__result.value()); + return __value; + } + inline std::shared_ptr> compress_audio(const std::string& fileUrl, const std::shared_ptr& optionMap) override { + auto __result = _swiftPart.compress_audio(fileUrl, optionMap); + if (__result.hasError()) [[unlikely]] { + std::rethrow_exception(__result.error()); + } + auto __value = std::move(__result.value()); + return __value; + } + inline std::shared_ptr>> upload(const std::string& fileUrl, const std::shared_ptr& options, const std::optional>& onProgress) override { + auto __result = _swiftPart.upload(fileUrl, options, onProgress); + if (__result.hasError()) [[unlikely]] { + std::rethrow_exception(__result.error()); + } + auto __value = std::move(__result.value()); + return __value; + } + inline void cancelUpload(const std::string& uuid, bool shouldCancelAll) override { + auto __result = _swiftPart.cancelUpload(uuid, std::forward(shouldCancelAll)); + if (__result.hasError()) [[unlikely]] { + std::rethrow_exception(__result.error()); + } + } + inline std::shared_ptr> download(const std::string& fileUrl, const std::shared_ptr& options, const std::optional>& onProgress) override { + auto __result = _swiftPart.download(fileUrl, options, onProgress); + if (__result.hasError()) [[unlikely]] { + std::rethrow_exception(__result.error()); + } + auto __value = std::move(__result.value()); + return __value; + } + inline std::shared_ptr> generateFilePath(const std::string& fileExtension) override { + auto __result = _swiftPart.generateFilePath(fileExtension); + if (__result.hasError()) [[unlikely]] { + std::rethrow_exception(__result.error()); + } + auto __value = std::move(__result.value()); + return __value; + } + inline std::shared_ptr> getRealPath(const std::string& path, const std::string& type) override { + auto __result = _swiftPart.getRealPath(path, type); + if (__result.hasError()) [[unlikely]] { + std::rethrow_exception(__result.error()); + } + auto __value = std::move(__result.value()); + return __value; + } + inline std::shared_ptr> getFileSize(const std::string& filePath) override { + auto __result = _swiftPart.getFileSize(filePath); + if (__result.hasError()) [[unlikely]] { + std::rethrow_exception(__result.error()); + } + auto __value = std::move(__result.value()); + return __value; + } + inline std::shared_ptr> createVideoThumbnail(const std::string& fileUrl, const std::shared_ptr& options) override { + auto __result = _swiftPart.createVideoThumbnail(fileUrl, options); + if (__result.hasError()) [[unlikely]] { + std::rethrow_exception(__result.error()); + } + auto __value = std::move(__result.value()); + return __value; + } + inline std::shared_ptr> clearCache(const std::optional& cacheDir) override { + auto __result = _swiftPart.clearCache(cacheDir); + if (__result.hasError()) [[unlikely]] { + std::rethrow_exception(__result.error()); + } + auto __value = std::move(__result.value()); + return __value; + } + + private: + react_native_compressor::HybridCompressorSpec_cxx _swiftPart; + }; + +} // namespace margelo::nitro::compressor diff --git a/nitrogen/generated/ios/react_native_compressor+autolinking.rb b/nitrogen/generated/ios/react_native_compressor+autolinking.rb new file mode 100644 index 00000000..41a61c9f --- /dev/null +++ b/nitrogen/generated/ios/react_native_compressor+autolinking.rb @@ -0,0 +1,62 @@ +# +# react_native_compressor+autolinking.rb +# This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +# https://github.com/mrousavy/nitro +# Copyright © Marc Rousavy @ Margelo +# + +# This is a Ruby script that adds all files generated by Nitrogen +# to the given podspec. +# +# To use it, add this to your .podspec: +# ```ruby +# Pod::Spec.new do |spec| +# # ... +# +# # Add all files generated by Nitrogen +# load 'nitrogen/generated/ios/react_native_compressor+autolinking.rb' +# add_nitrogen_files(spec) +# end +# ``` + +def add_nitrogen_files(spec) + Pod::UI.puts "[NitroModules] 🔥 react_native_compressor is boosted by nitro!" + + spec.dependency "NitroModules" + + current_source_files = Array(spec.attributes_hash['source_files']) + spec.source_files = current_source_files + [ + # Generated cross-platform specs + "nitrogen/generated/shared/**/*.{h,hpp,c,cpp,swift}", + # Generated bridges for the cross-platform specs + "nitrogen/generated/ios/**/*.{h,hpp,c,cpp,mm,swift}", + ] + + current_public_header_files = Array(spec.attributes_hash['public_header_files']) + spec.public_header_files = current_public_header_files + [ + # Generated specs + "nitrogen/generated/shared/**/*.{h,hpp}", + # Swift to C++ bridging helpers + "nitrogen/generated/ios/react_native_compressor-Swift-Cxx-Bridge.hpp" + ] + + current_private_header_files = Array(spec.attributes_hash['private_header_files']) + spec.private_header_files = current_private_header_files + [ + # iOS specific specs + "nitrogen/generated/ios/c++/**/*.{h,hpp}", + # Views are framework-specific and should be private + "nitrogen/generated/shared/**/views/**/*" + ] + + current_pod_target_xcconfig = spec.attributes_hash['pod_target_xcconfig'] || {} + spec.pod_target_xcconfig = current_pod_target_xcconfig.merge({ + # Use C++ 20 + "CLANG_CXX_LANGUAGE_STANDARD" => "c++20", + # Enables C++ <-> Swift interop (by default it's only ObjC) + "SWIFT_OBJC_INTEROP_MODE" => "objcxx", + # Enables stricter modular headers + "DEFINES_MODULE" => "YES", + # Disable auto-generated ObjC header for Swift (Static linkage on Xcode 26.4 breaks here) + "SWIFT_INSTALL_OBJC_HEADER" => "NO", + }) +end diff --git a/nitrogen/generated/ios/react_native_compressor-Swift-Cxx-Bridge.cpp b/nitrogen/generated/ios/react_native_compressor-Swift-Cxx-Bridge.cpp new file mode 100644 index 00000000..36121c3c --- /dev/null +++ b/nitrogen/generated/ios/react_native_compressor-Swift-Cxx-Bridge.cpp @@ -0,0 +1,89 @@ +/// +/// react_native_compressor-Swift-Cxx-Bridge.cpp +/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +/// https://github.com/mrousavy/nitro +/// Copyright © Marc Rousavy @ Margelo +/// + +#include "react_native_compressor-Swift-Cxx-Bridge.hpp" + +// Include C++ implementation defined types +#include "HybridCompressorSpecSwift.hpp" +#include "react_native_compressor-Swift-Cxx-Umbrella.hpp" +#include + +namespace margelo::nitro::compressor::bridge::swift { + + // pragma MARK: std::function + Func_void_std__string create_Func_void_std__string(void* NON_NULL swiftClosureWrapper) noexcept { + auto swiftClosure = react_native_compressor::Func_void_std__string::fromUnsafe(swiftClosureWrapper); + return [swiftClosure = std::move(swiftClosure)](const std::string& result) mutable -> void { + swiftClosure.call(result); + }; + } + + // pragma MARK: std::function + Func_void_std__exception_ptr create_Func_void_std__exception_ptr(void* NON_NULL swiftClosureWrapper) noexcept { + auto swiftClosure = react_native_compressor::Func_void_std__exception_ptr::fromUnsafe(swiftClosureWrapper); + return [swiftClosure = std::move(swiftClosure)](const std::exception_ptr& error) mutable -> void { + swiftClosure.call(error); + }; + } + + // pragma MARK: std::function + Func_void_double create_Func_void_double(void* NON_NULL swiftClosureWrapper) noexcept { + auto swiftClosure = react_native_compressor::Func_void_double::fromUnsafe(swiftClosureWrapper); + return [swiftClosure = std::move(swiftClosure)](double progress) mutable -> void { + swiftClosure.call(progress); + }; + } + + // pragma MARK: std::function& /* result */)> + Func_void_std__shared_ptr_AnyMap_ create_Func_void_std__shared_ptr_AnyMap_(void* NON_NULL swiftClosureWrapper) noexcept { + auto swiftClosure = react_native_compressor::Func_void_std__shared_ptr_AnyMap_::fromUnsafe(swiftClosureWrapper); + return [swiftClosure = std::move(swiftClosure)](const std::shared_ptr& result) mutable -> void { + swiftClosure.call(result); + }; + } + + // pragma MARK: std::function + Func_void create_Func_void(void* NON_NULL swiftClosureWrapper) noexcept { + auto swiftClosure = react_native_compressor::Func_void::fromUnsafe(swiftClosureWrapper); + return [swiftClosure = std::move(swiftClosure)]() mutable -> void { + swiftClosure.call(); + }; + } + + // pragma MARK: std::function + Func_void_double_double create_Func_void_double_double(void* NON_NULL swiftClosureWrapper) noexcept { + auto swiftClosure = react_native_compressor::Func_void_double_double::fromUnsafe(swiftClosureWrapper); + return [swiftClosure = std::move(swiftClosure)](double written, double total) mutable -> void { + swiftClosure.call(written, total); + }; + } + + // pragma MARK: std::function + Func_void_VideoThumbnailResult create_Func_void_VideoThumbnailResult(void* NON_NULL swiftClosureWrapper) noexcept { + auto swiftClosure = react_native_compressor::Func_void_VideoThumbnailResult::fromUnsafe(swiftClosureWrapper); + return [swiftClosure = std::move(swiftClosure)](const VideoThumbnailResult& result) mutable -> void { + swiftClosure.call(result); + }; + } + + // pragma MARK: std::shared_ptr + std::shared_ptr create_std__shared_ptr_HybridCompressorSpec_(void* NON_NULL swiftUnsafePointer) noexcept { + react_native_compressor::HybridCompressorSpec_cxx swiftPart = react_native_compressor::HybridCompressorSpec_cxx::fromUnsafe(swiftUnsafePointer); + return std::make_shared(swiftPart); + } + void* NON_NULL get_std__shared_ptr_HybridCompressorSpec_(std__shared_ptr_HybridCompressorSpec_ cppType) { + std::shared_ptr swiftWrapper = std::dynamic_pointer_cast(cppType); + #ifdef NITRO_DEBUG + if (swiftWrapper == nullptr) [[unlikely]] { + throw std::runtime_error("Class \"HybridCompressorSpec\" is not implemented in Swift!"); + } + #endif + react_native_compressor::HybridCompressorSpec_cxx& swiftPart = swiftWrapper->getSwiftPart(); + return swiftPart.toUnsafe(); + } + +} // namespace margelo::nitro::compressor::bridge::swift diff --git a/nitrogen/generated/ios/react_native_compressor-Swift-Cxx-Bridge.hpp b/nitrogen/generated/ios/react_native_compressor-Swift-Cxx-Bridge.hpp new file mode 100644 index 00000000..7c9d0013 --- /dev/null +++ b/nitrogen/generated/ios/react_native_compressor-Swift-Cxx-Bridge.hpp @@ -0,0 +1,337 @@ +/// +/// react_native_compressor-Swift-Cxx-Bridge.hpp +/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +/// https://github.com/mrousavy/nitro +/// Copyright © Marc Rousavy @ Margelo +/// + +#pragma once + +// Forward declarations of C++ defined types +// Forward declaration of `HybridCompressorSpec` to properly resolve imports. +namespace margelo::nitro::compressor { class HybridCompressorSpec; } +// Forward declaration of `VideoThumbnailResult` to properly resolve imports. +namespace margelo::nitro::compressor { struct VideoThumbnailResult; } + +// Forward declarations of Swift defined types +// Forward declaration of `HybridCompressorSpec_cxx` to properly resolve imports. +namespace react_native_compressor { class HybridCompressorSpec_cxx; } + +// Include C++ defined types +#include "HybridCompressorSpec.hpp" +#include "VideoThumbnailResult.hpp" +#include +#include +#include +#include +#include +#include +#include +#include +#include + +/** + * Contains specialized versions of C++ templated types so they can be accessed from Swift, + * as well as helper functions to interact with those C++ types from Swift. + */ +namespace margelo::nitro::compressor::bridge::swift { + + // pragma MARK: std::shared_ptr> + /** + * Specialized version of `std::shared_ptr>`. + */ + using std__shared_ptr_Promise_std__string__ = std::shared_ptr>; + inline std::shared_ptr> create_std__shared_ptr_Promise_std__string__() noexcept { + return Promise::create(); + } + inline PromiseHolder wrap_std__shared_ptr_Promise_std__string__(std::shared_ptr> promise) noexcept { + return PromiseHolder(std::move(promise)); + } + + // pragma MARK: std::function + /** + * Specialized version of `std::function`. + */ + using Func_void_std__string = std::function; + /** + * Wrapper class for a `std::function`, this can be used from Swift. + */ + class Func_void_std__string_Wrapper final { + public: + explicit Func_void_std__string_Wrapper(std::function&& func): _function(std::make_unique>(std::move(func))) {} + inline void call(std::string result) const noexcept { + _function->operator()(result); + } + private: + std::unique_ptr> _function; + } SWIFT_NONCOPYABLE; + Func_void_std__string create_Func_void_std__string(void* NON_NULL swiftClosureWrapper) noexcept; + inline Func_void_std__string_Wrapper wrap_Func_void_std__string(Func_void_std__string value) noexcept { + return Func_void_std__string_Wrapper(std::move(value)); + } + + // pragma MARK: std::function + /** + * Specialized version of `std::function`. + */ + using Func_void_std__exception_ptr = std::function; + /** + * Wrapper class for a `std::function`, this can be used from Swift. + */ + class Func_void_std__exception_ptr_Wrapper final { + public: + explicit Func_void_std__exception_ptr_Wrapper(std::function&& func): _function(std::make_unique>(std::move(func))) {} + inline void call(std::exception_ptr error) const noexcept { + _function->operator()(error); + } + private: + std::unique_ptr> _function; + } SWIFT_NONCOPYABLE; + Func_void_std__exception_ptr create_Func_void_std__exception_ptr(void* NON_NULL swiftClosureWrapper) noexcept; + inline Func_void_std__exception_ptr_Wrapper wrap_Func_void_std__exception_ptr(Func_void_std__exception_ptr value) noexcept { + return Func_void_std__exception_ptr_Wrapper(std::move(value)); + } + + // pragma MARK: std::function + /** + * Specialized version of `std::function`. + */ + using Func_void_double = std::function; + /** + * Wrapper class for a `std::function`, this can be used from Swift. + */ + class Func_void_double_Wrapper final { + public: + explicit Func_void_double_Wrapper(std::function&& func): _function(std::make_unique>(std::move(func))) {} + inline void call(double progress) const noexcept { + _function->operator()(progress); + } + private: + std::unique_ptr> _function; + } SWIFT_NONCOPYABLE; + Func_void_double create_Func_void_double(void* NON_NULL swiftClosureWrapper) noexcept; + inline Func_void_double_Wrapper wrap_Func_void_double(Func_void_double value) noexcept { + return Func_void_double_Wrapper(std::move(value)); + } + + // pragma MARK: std::optional> + /** + * Specialized version of `std::optional>`. + */ + using std__optional_std__function_void_double____progress______ = std::optional>; + inline std::optional> create_std__optional_std__function_void_double____progress______(const std::function& value) noexcept { + return std::optional>(value); + } + inline bool has_value_std__optional_std__function_void_double____progress______(const std::optional>& optional) noexcept { + return optional.has_value(); + } + inline std::function get_std__optional_std__function_void_double____progress______(const std::optional>& optional) noexcept { + return optional.value(); + } + + // pragma MARK: std::shared_ptr>> + /** + * Specialized version of `std::shared_ptr>>`. + */ + using std__shared_ptr_Promise_std__shared_ptr_AnyMap___ = std::shared_ptr>>; + inline std::shared_ptr>> create_std__shared_ptr_Promise_std__shared_ptr_AnyMap___() noexcept { + return Promise>::create(); + } + inline PromiseHolder> wrap_std__shared_ptr_Promise_std__shared_ptr_AnyMap___(std::shared_ptr>> promise) noexcept { + return PromiseHolder>(std::move(promise)); + } + + // pragma MARK: std::function& /* result */)> + /** + * Specialized version of `std::function&)>`. + */ + using Func_void_std__shared_ptr_AnyMap_ = std::function& /* result */)>; + /** + * Wrapper class for a `std::function& / * result * /)>`, this can be used from Swift. + */ + class Func_void_std__shared_ptr_AnyMap__Wrapper final { + public: + explicit Func_void_std__shared_ptr_AnyMap__Wrapper(std::function& /* result */)>&& func): _function(std::make_unique& /* result */)>>(std::move(func))) {} + inline void call(std::shared_ptr result) const noexcept { + _function->operator()(result); + } + private: + std::unique_ptr& /* result */)>> _function; + } SWIFT_NONCOPYABLE; + Func_void_std__shared_ptr_AnyMap_ create_Func_void_std__shared_ptr_AnyMap_(void* NON_NULL swiftClosureWrapper) noexcept; + inline Func_void_std__shared_ptr_AnyMap__Wrapper wrap_Func_void_std__shared_ptr_AnyMap_(Func_void_std__shared_ptr_AnyMap_ value) noexcept { + return Func_void_std__shared_ptr_AnyMap__Wrapper(std::move(value)); + } + + // pragma MARK: std::function + /** + * Specialized version of `std::function`. + */ + using Func_void = std::function; + /** + * Wrapper class for a `std::function`, this can be used from Swift. + */ + class Func_void_Wrapper final { + public: + explicit Func_void_Wrapper(std::function&& func): _function(std::make_unique>(std::move(func))) {} + inline void call() const noexcept { + _function->operator()(); + } + private: + std::unique_ptr> _function; + } SWIFT_NONCOPYABLE; + Func_void create_Func_void(void* NON_NULL swiftClosureWrapper) noexcept; + inline Func_void_Wrapper wrap_Func_void(Func_void value) noexcept { + return Func_void_Wrapper(std::move(value)); + } + + // pragma MARK: std::optional> + /** + * Specialized version of `std::optional>`. + */ + using std__optional_std__function_void____ = std::optional>; + inline std::optional> create_std__optional_std__function_void____(const std::function& value) noexcept { + return std::optional>(value); + } + inline bool has_value_std__optional_std__function_void____(const std::optional>& optional) noexcept { + return optional.has_value(); + } + inline std::function get_std__optional_std__function_void____(const std::optional>& optional) noexcept { + return optional.value(); + } + + // pragma MARK: std::function + /** + * Specialized version of `std::function`. + */ + using Func_void_double_double = std::function; + /** + * Wrapper class for a `std::function`, this can be used from Swift. + */ + class Func_void_double_double_Wrapper final { + public: + explicit Func_void_double_double_Wrapper(std::function&& func): _function(std::make_unique>(std::move(func))) {} + inline void call(double written, double total) const noexcept { + _function->operator()(written, total); + } + private: + std::unique_ptr> _function; + } SWIFT_NONCOPYABLE; + Func_void_double_double create_Func_void_double_double(void* NON_NULL swiftClosureWrapper) noexcept; + inline Func_void_double_double_Wrapper wrap_Func_void_double_double(Func_void_double_double value) noexcept { + return Func_void_double_double_Wrapper(std::move(value)); + } + + // pragma MARK: std::optional> + /** + * Specialized version of `std::optional>`. + */ + using std__optional_std__function_void_double____written_____double____total______ = std::optional>; + inline std::optional> create_std__optional_std__function_void_double____written_____double____total______(const std::function& value) noexcept { + return std::optional>(value); + } + inline bool has_value_std__optional_std__function_void_double____written_____double____total______(const std::optional>& optional) noexcept { + return optional.has_value(); + } + inline std::function get_std__optional_std__function_void_double____written_____double____total______(const std::optional>& optional) noexcept { + return optional.value(); + } + + // pragma MARK: std::shared_ptr> + /** + * Specialized version of `std::shared_ptr>`. + */ + using std__shared_ptr_Promise_VideoThumbnailResult__ = std::shared_ptr>; + inline std::shared_ptr> create_std__shared_ptr_Promise_VideoThumbnailResult__() noexcept { + return Promise::create(); + } + inline PromiseHolder wrap_std__shared_ptr_Promise_VideoThumbnailResult__(std::shared_ptr> promise) noexcept { + return PromiseHolder(std::move(promise)); + } + + // pragma MARK: std::function + /** + * Specialized version of `std::function`. + */ + using Func_void_VideoThumbnailResult = std::function; + /** + * Wrapper class for a `std::function`, this can be used from Swift. + */ + class Func_void_VideoThumbnailResult_Wrapper final { + public: + explicit Func_void_VideoThumbnailResult_Wrapper(std::function&& func): _function(std::make_unique>(std::move(func))) {} + inline void call(VideoThumbnailResult result) const noexcept { + _function->operator()(result); + } + private: + std::unique_ptr> _function; + } SWIFT_NONCOPYABLE; + Func_void_VideoThumbnailResult create_Func_void_VideoThumbnailResult(void* NON_NULL swiftClosureWrapper) noexcept; + inline Func_void_VideoThumbnailResult_Wrapper wrap_Func_void_VideoThumbnailResult(Func_void_VideoThumbnailResult value) noexcept { + return Func_void_VideoThumbnailResult_Wrapper(std::move(value)); + } + + // pragma MARK: std::optional + /** + * Specialized version of `std::optional`. + */ + using std__optional_std__string_ = std::optional; + inline std::optional create_std__optional_std__string_(const std::string& value) noexcept { + return std::optional(value); + } + inline bool has_value_std__optional_std__string_(const std::optional& optional) noexcept { + return optional.has_value(); + } + inline std::string get_std__optional_std__string_(const std::optional& optional) noexcept { + return optional.value(); + } + + // pragma MARK: std::shared_ptr + /** + * Specialized version of `std::shared_ptr`. + */ + using std__shared_ptr_HybridCompressorSpec_ = std::shared_ptr; + std::shared_ptr create_std__shared_ptr_HybridCompressorSpec_(void* NON_NULL swiftUnsafePointer) noexcept; + void* NON_NULL get_std__shared_ptr_HybridCompressorSpec_(std__shared_ptr_HybridCompressorSpec_ cppType); + + // pragma MARK: std::weak_ptr + using std__weak_ptr_HybridCompressorSpec_ = std::weak_ptr; + inline std__weak_ptr_HybridCompressorSpec_ weakify_std__shared_ptr_HybridCompressorSpec_(const std::shared_ptr& strong) noexcept { return strong; } + + // pragma MARK: Result>> + using Result_std__shared_ptr_Promise_std__string___ = Result>>; + inline Result_std__shared_ptr_Promise_std__string___ create_Result_std__shared_ptr_Promise_std__string___(const std::shared_ptr>& value) noexcept { + return Result>>::withValue(value); + } + inline Result_std__shared_ptr_Promise_std__string___ create_Result_std__shared_ptr_Promise_std__string___(const std::exception_ptr& error) noexcept { + return Result>>::withError(error); + } + + // pragma MARK: Result>>> + using Result_std__shared_ptr_Promise_std__shared_ptr_AnyMap____ = Result>>>; + inline Result_std__shared_ptr_Promise_std__shared_ptr_AnyMap____ create_Result_std__shared_ptr_Promise_std__shared_ptr_AnyMap____(const std::shared_ptr>>& value) noexcept { + return Result>>>::withValue(value); + } + inline Result_std__shared_ptr_Promise_std__shared_ptr_AnyMap____ create_Result_std__shared_ptr_Promise_std__shared_ptr_AnyMap____(const std::exception_ptr& error) noexcept { + return Result>>>::withError(error); + } + + // pragma MARK: Result + using Result_void_ = Result; + inline Result_void_ create_Result_void_() noexcept { + return Result::withValue(); + } + inline Result_void_ create_Result_void_(const std::exception_ptr& error) noexcept { + return Result::withError(error); + } + + // pragma MARK: Result>> + using Result_std__shared_ptr_Promise_VideoThumbnailResult___ = Result>>; + inline Result_std__shared_ptr_Promise_VideoThumbnailResult___ create_Result_std__shared_ptr_Promise_VideoThumbnailResult___(const std::shared_ptr>& value) noexcept { + return Result>>::withValue(value); + } + inline Result_std__shared_ptr_Promise_VideoThumbnailResult___ create_Result_std__shared_ptr_Promise_VideoThumbnailResult___(const std::exception_ptr& error) noexcept { + return Result>>::withError(error); + } + +} // namespace margelo::nitro::compressor::bridge::swift diff --git a/nitrogen/generated/ios/react_native_compressor-Swift-Cxx-Umbrella.hpp b/nitrogen/generated/ios/react_native_compressor-Swift-Cxx-Umbrella.hpp new file mode 100644 index 00000000..7a634ba1 --- /dev/null +++ b/nitrogen/generated/ios/react_native_compressor-Swift-Cxx-Umbrella.hpp @@ -0,0 +1,51 @@ +/// +/// react_native_compressor-Swift-Cxx-Umbrella.hpp +/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +/// https://github.com/mrousavy/nitro +/// Copyright © Marc Rousavy @ Margelo +/// + +#pragma once + +// Forward declarations of C++ defined types +// Forward declaration of `HybridCompressorSpec` to properly resolve imports. +namespace margelo::nitro::compressor { class HybridCompressorSpec; } +// Forward declaration of `VideoThumbnailResult` to properly resolve imports. +namespace margelo::nitro::compressor { struct VideoThumbnailResult; } + +// Include C++ defined types +#include "HybridCompressorSpec.hpp" +#include "VideoThumbnailResult.hpp" +#include +#include +#include +#include +#include +#include +#include +#include + +// C++ helpers for Swift +#include "react_native_compressor-Swift-Cxx-Bridge.hpp" + +// Common C++ types used in Swift +#include +#include +#include +#include + +// Forward declarations of Swift defined types +// Forward declaration of `HybridCompressorSpec_cxx` to properly resolve imports. +namespace react_native_compressor { class HybridCompressorSpec_cxx; } + +// Include Swift defined types +#if __has_include("react_native_compressor-Swift.h") +// This header is generated by Xcode/Swift on every app build. +// If it cannot be found, make sure the Swift module's name (= podspec name) is actually "react_native_compressor". +#include "react_native_compressor-Swift.h" +// Same as above, but used when building with frameworks (`use_frameworks`) +#elif __has_include() +#include +#else +#error react_native_compressor's autogenerated Swift header cannot be found! Make sure the Swift module's name (= podspec name) is actually "react_native_compressor", and try building the app first. +#endif diff --git a/nitrogen/generated/ios/react_native_compressorAutolinking.mm b/nitrogen/generated/ios/react_native_compressorAutolinking.mm new file mode 100644 index 00000000..f9fe8094 --- /dev/null +++ b/nitrogen/generated/ios/react_native_compressorAutolinking.mm @@ -0,0 +1,33 @@ +/// +/// react_native_compressorAutolinking.mm +/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +/// https://github.com/mrousavy/nitro +/// Copyright © Marc Rousavy @ Margelo +/// + +#import +#import +#import "react_native_compressor-Swift-Cxx-Umbrella.hpp" +#import + +#include "HybridCompressorSpecSwift.hpp" + +@interface react_native_compressorAutolinking : NSObject +@end + +@implementation react_native_compressorAutolinking + ++ (void) load { + using namespace margelo::nitro; + using namespace margelo::nitro::compressor; + + HybridObjectRegistry::registerHybridObjectConstructor( + "Compressor", + []() -> std::shared_ptr { + std::shared_ptr hybridObject = react_native_compressor::react_native_compressorAutolinking::createCompressor(); + return hybridObject; + } + ); +} + +@end diff --git a/nitrogen/generated/ios/react_native_compressorAutolinking.swift b/nitrogen/generated/ios/react_native_compressorAutolinking.swift new file mode 100644 index 00000000..5cc310bb --- /dev/null +++ b/nitrogen/generated/ios/react_native_compressorAutolinking.swift @@ -0,0 +1,26 @@ +/// +/// react_native_compressorAutolinking.swift +/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +/// https://github.com/mrousavy/nitro +/// Copyright © Marc Rousavy @ Margelo +/// + +import NitroModules + +// TODO: Use empty enums once Swift supports exporting them as namespaces +// See: https://github.com/swiftlang/swift/pull/83616 +public final class react_native_compressorAutolinking { + public typealias bridge = margelo.nitro.compressor.bridge.swift + + public static func createCompressor() -> bridge.std__shared_ptr_HybridCompressorSpec_ { + let hybridObject = HybridCompressor() + return { () -> bridge.std__shared_ptr_HybridCompressorSpec_ in + let __cxxWrapped = hybridObject.getCxxWrapper() + return __cxxWrapped.getCxxPart() + }() + } + + public static func isCompressorRecyclable() -> Bool { + return HybridCompressor.self is any RecyclableView.Type + } +} diff --git a/nitrogen/generated/ios/swift/Func_void.swift b/nitrogen/generated/ios/swift/Func_void.swift new file mode 100644 index 00000000..e017ff5b --- /dev/null +++ b/nitrogen/generated/ios/swift/Func_void.swift @@ -0,0 +1,46 @@ +/// +/// Func_void.swift +/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +/// https://github.com/mrousavy/nitro +/// Copyright © Marc Rousavy @ Margelo +/// + +import NitroModules + +/** + * Wraps a Swift `() -> Void` as a class. + * This class can be used from C++, e.g. to wrap the Swift closure as a `std::function`. + */ +public final class Func_void { + public typealias bridge = margelo.nitro.compressor.bridge.swift + + private let closure: () -> Void + + public init(_ closure: @escaping () -> Void) { + self.closure = closure + } + + @inline(__always) + public func call() -> Void { + self.closure() + } + + /** + * Casts this instance to a retained unsafe raw pointer. + * This acquires one additional strong reference on the object! + */ + @inline(__always) + public func toUnsafe() -> UnsafeMutableRawPointer { + return Unmanaged.passRetained(self).toOpaque() + } + + /** + * Casts an unsafe pointer to a `Func_void`. + * The pointer has to be a retained opaque `Unmanaged`. + * This removes one strong reference from the object! + */ + @inline(__always) + public static func fromUnsafe(_ pointer: UnsafeMutableRawPointer) -> Func_void { + return Unmanaged.fromOpaque(pointer).takeRetainedValue() + } +} diff --git a/nitrogen/generated/ios/swift/Func_void_VideoThumbnailResult.swift b/nitrogen/generated/ios/swift/Func_void_VideoThumbnailResult.swift new file mode 100644 index 00000000..2f465de7 --- /dev/null +++ b/nitrogen/generated/ios/swift/Func_void_VideoThumbnailResult.swift @@ -0,0 +1,46 @@ +/// +/// Func_void_VideoThumbnailResult.swift +/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +/// https://github.com/mrousavy/nitro +/// Copyright © Marc Rousavy @ Margelo +/// + +import NitroModules + +/** + * Wraps a Swift `(_ value: VideoThumbnailResult) -> Void` as a class. + * This class can be used from C++, e.g. to wrap the Swift closure as a `std::function`. + */ +public final class Func_void_VideoThumbnailResult { + public typealias bridge = margelo.nitro.compressor.bridge.swift + + private let closure: (_ value: VideoThumbnailResult) -> Void + + public init(_ closure: @escaping (_ value: VideoThumbnailResult) -> Void) { + self.closure = closure + } + + @inline(__always) + public func call(value: VideoThumbnailResult) -> Void { + self.closure(value) + } + + /** + * Casts this instance to a retained unsafe raw pointer. + * This acquires one additional strong reference on the object! + */ + @inline(__always) + public func toUnsafe() -> UnsafeMutableRawPointer { + return Unmanaged.passRetained(self).toOpaque() + } + + /** + * Casts an unsafe pointer to a `Func_void_VideoThumbnailResult`. + * The pointer has to be a retained opaque `Unmanaged`. + * This removes one strong reference from the object! + */ + @inline(__always) + public static func fromUnsafe(_ pointer: UnsafeMutableRawPointer) -> Func_void_VideoThumbnailResult { + return Unmanaged.fromOpaque(pointer).takeRetainedValue() + } +} diff --git a/nitrogen/generated/ios/swift/Func_void_double.swift b/nitrogen/generated/ios/swift/Func_void_double.swift new file mode 100644 index 00000000..493b6e21 --- /dev/null +++ b/nitrogen/generated/ios/swift/Func_void_double.swift @@ -0,0 +1,46 @@ +/// +/// Func_void_double.swift +/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +/// https://github.com/mrousavy/nitro +/// Copyright © Marc Rousavy @ Margelo +/// + +import NitroModules + +/** + * Wraps a Swift `(_ progress: Double) -> Void` as a class. + * This class can be used from C++, e.g. to wrap the Swift closure as a `std::function`. + */ +public final class Func_void_double { + public typealias bridge = margelo.nitro.compressor.bridge.swift + + private let closure: (_ progress: Double) -> Void + + public init(_ closure: @escaping (_ progress: Double) -> Void) { + self.closure = closure + } + + @inline(__always) + public func call(progress: Double) -> Void { + self.closure(progress) + } + + /** + * Casts this instance to a retained unsafe raw pointer. + * This acquires one additional strong reference on the object! + */ + @inline(__always) + public func toUnsafe() -> UnsafeMutableRawPointer { + return Unmanaged.passRetained(self).toOpaque() + } + + /** + * Casts an unsafe pointer to a `Func_void_double`. + * The pointer has to be a retained opaque `Unmanaged`. + * This removes one strong reference from the object! + */ + @inline(__always) + public static func fromUnsafe(_ pointer: UnsafeMutableRawPointer) -> Func_void_double { + return Unmanaged.fromOpaque(pointer).takeRetainedValue() + } +} diff --git a/nitrogen/generated/ios/swift/Func_void_double_double.swift b/nitrogen/generated/ios/swift/Func_void_double_double.swift new file mode 100644 index 00000000..acf0c2f0 --- /dev/null +++ b/nitrogen/generated/ios/swift/Func_void_double_double.swift @@ -0,0 +1,46 @@ +/// +/// Func_void_double_double.swift +/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +/// https://github.com/mrousavy/nitro +/// Copyright © Marc Rousavy @ Margelo +/// + +import NitroModules + +/** + * Wraps a Swift `(_ written: Double, _ total: Double) -> Void` as a class. + * This class can be used from C++, e.g. to wrap the Swift closure as a `std::function`. + */ +public final class Func_void_double_double { + public typealias bridge = margelo.nitro.compressor.bridge.swift + + private let closure: (_ written: Double, _ total: Double) -> Void + + public init(_ closure: @escaping (_ written: Double, _ total: Double) -> Void) { + self.closure = closure + } + + @inline(__always) + public func call(written: Double, total: Double) -> Void { + self.closure(written, total) + } + + /** + * Casts this instance to a retained unsafe raw pointer. + * This acquires one additional strong reference on the object! + */ + @inline(__always) + public func toUnsafe() -> UnsafeMutableRawPointer { + return Unmanaged.passRetained(self).toOpaque() + } + + /** + * Casts an unsafe pointer to a `Func_void_double_double`. + * The pointer has to be a retained opaque `Unmanaged`. + * This removes one strong reference from the object! + */ + @inline(__always) + public static func fromUnsafe(_ pointer: UnsafeMutableRawPointer) -> Func_void_double_double { + return Unmanaged.fromOpaque(pointer).takeRetainedValue() + } +} diff --git a/nitrogen/generated/ios/swift/Func_void_std__exception_ptr.swift b/nitrogen/generated/ios/swift/Func_void_std__exception_ptr.swift new file mode 100644 index 00000000..a575b39d --- /dev/null +++ b/nitrogen/generated/ios/swift/Func_void_std__exception_ptr.swift @@ -0,0 +1,46 @@ +/// +/// Func_void_std__exception_ptr.swift +/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +/// https://github.com/mrousavy/nitro +/// Copyright © Marc Rousavy @ Margelo +/// + +import NitroModules + +/** + * Wraps a Swift `(_ error: Error) -> Void` as a class. + * This class can be used from C++, e.g. to wrap the Swift closure as a `std::function`. + */ +public final class Func_void_std__exception_ptr { + public typealias bridge = margelo.nitro.compressor.bridge.swift + + private let closure: (_ error: Error) -> Void + + public init(_ closure: @escaping (_ error: Error) -> Void) { + self.closure = closure + } + + @inline(__always) + public func call(error: std.exception_ptr) -> Void { + self.closure(RuntimeError.from(cppError: error)) + } + + /** + * Casts this instance to a retained unsafe raw pointer. + * This acquires one additional strong reference on the object! + */ + @inline(__always) + public func toUnsafe() -> UnsafeMutableRawPointer { + return Unmanaged.passRetained(self).toOpaque() + } + + /** + * Casts an unsafe pointer to a `Func_void_std__exception_ptr`. + * The pointer has to be a retained opaque `Unmanaged`. + * This removes one strong reference from the object! + */ + @inline(__always) + public static func fromUnsafe(_ pointer: UnsafeMutableRawPointer) -> Func_void_std__exception_ptr { + return Unmanaged.fromOpaque(pointer).takeRetainedValue() + } +} diff --git a/nitrogen/generated/ios/swift/Func_void_std__shared_ptr_AnyMap_.swift b/nitrogen/generated/ios/swift/Func_void_std__shared_ptr_AnyMap_.swift new file mode 100644 index 00000000..2d5a37c5 --- /dev/null +++ b/nitrogen/generated/ios/swift/Func_void_std__shared_ptr_AnyMap_.swift @@ -0,0 +1,46 @@ +/// +/// Func_void_std__shared_ptr_AnyMap_.swift +/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +/// https://github.com/mrousavy/nitro +/// Copyright © Marc Rousavy @ Margelo +/// + +import NitroModules + +/** + * Wraps a Swift `(_ value: AnyMap) -> Void` as a class. + * This class can be used from C++, e.g. to wrap the Swift closure as a `std::function`. + */ +public final class Func_void_std__shared_ptr_AnyMap_ { + public typealias bridge = margelo.nitro.compressor.bridge.swift + + private let closure: (_ value: AnyMap) -> Void + + public init(_ closure: @escaping (_ value: AnyMap) -> Void) { + self.closure = closure + } + + @inline(__always) + public func call(value: margelo.nitro.SharedAnyMap) -> Void { + self.closure(AnyMap(withCppPart: value)) + } + + /** + * Casts this instance to a retained unsafe raw pointer. + * This acquires one additional strong reference on the object! + */ + @inline(__always) + public func toUnsafe() -> UnsafeMutableRawPointer { + return Unmanaged.passRetained(self).toOpaque() + } + + /** + * Casts an unsafe pointer to a `Func_void_std__shared_ptr_AnyMap_`. + * The pointer has to be a retained opaque `Unmanaged`. + * This removes one strong reference from the object! + */ + @inline(__always) + public static func fromUnsafe(_ pointer: UnsafeMutableRawPointer) -> Func_void_std__shared_ptr_AnyMap_ { + return Unmanaged.fromOpaque(pointer).takeRetainedValue() + } +} diff --git a/nitrogen/generated/ios/swift/Func_void_std__string.swift b/nitrogen/generated/ios/swift/Func_void_std__string.swift new file mode 100644 index 00000000..c13cb0d6 --- /dev/null +++ b/nitrogen/generated/ios/swift/Func_void_std__string.swift @@ -0,0 +1,46 @@ +/// +/// Func_void_std__string.swift +/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +/// https://github.com/mrousavy/nitro +/// Copyright © Marc Rousavy @ Margelo +/// + +import NitroModules + +/** + * Wraps a Swift `(_ value: String) -> Void` as a class. + * This class can be used from C++, e.g. to wrap the Swift closure as a `std::function`. + */ +public final class Func_void_std__string { + public typealias bridge = margelo.nitro.compressor.bridge.swift + + private let closure: (_ value: String) -> Void + + public init(_ closure: @escaping (_ value: String) -> Void) { + self.closure = closure + } + + @inline(__always) + public func call(value: std.string) -> Void { + self.closure(String(value)) + } + + /** + * Casts this instance to a retained unsafe raw pointer. + * This acquires one additional strong reference on the object! + */ + @inline(__always) + public func toUnsafe() -> UnsafeMutableRawPointer { + return Unmanaged.passRetained(self).toOpaque() + } + + /** + * Casts an unsafe pointer to a `Func_void_std__string`. + * The pointer has to be a retained opaque `Unmanaged`. + * This removes one strong reference from the object! + */ + @inline(__always) + public static func fromUnsafe(_ pointer: UnsafeMutableRawPointer) -> Func_void_std__string { + return Unmanaged.fromOpaque(pointer).takeRetainedValue() + } +} diff --git a/nitrogen/generated/ios/swift/HybridCompressorSpec.swift b/nitrogen/generated/ios/swift/HybridCompressorSpec.swift new file mode 100644 index 00000000..86deb91e --- /dev/null +++ b/nitrogen/generated/ios/swift/HybridCompressorSpec.swift @@ -0,0 +1,70 @@ +/// +/// HybridCompressorSpec.swift +/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +/// https://github.com/mrousavy/nitro +/// Copyright © Marc Rousavy @ Margelo +/// + +import NitroModules + +/// See ``HybridCompressorSpec`` +public protocol HybridCompressorSpec_protocol: HybridObject { + // Properties + + + // Methods + func image_compress(imagePath: String, optionMap: AnyMap, onDownloadProgress: ((_ progress: Double) -> Void)?) throws -> Promise + func getImageMetaData(filePath: String) throws -> Promise + func compress(fileUrl: String, optionMap: AnyMap, onProgress: ((_ progress: Double) -> Void)?, onDownloadProgress: ((_ progress: Double) -> Void)?) throws -> Promise + func cancelCompression(uuid: String) throws -> Void + func getVideoMetaData(filePath: String) throws -> Promise + func activateBackgroundTask(options: AnyMap, onExpired: (() -> Void)?) throws -> Promise + func deactivateBackgroundTask(options: AnyMap) throws -> Promise + func compress_audio(fileUrl: String, optionMap: AnyMap) throws -> Promise + func upload(fileUrl: String, options: AnyMap, onProgress: ((_ written: Double, _ total: Double) -> Void)?) throws -> Promise + func cancelUpload(uuid: String, shouldCancelAll: Bool) throws -> Void + func download(fileUrl: String, options: AnyMap, onProgress: ((_ progress: Double) -> Void)?) throws -> Promise + func generateFilePath(fileExtension: String) throws -> Promise + func getRealPath(path: String, type: String) throws -> Promise + func getFileSize(filePath: String) throws -> Promise + func createVideoThumbnail(fileUrl: String, options: AnyMap) throws -> Promise + func clearCache(cacheDir: String?) throws -> Promise +} + +public extension HybridCompressorSpec_protocol { + /// Default implementation of ``HybridObject.toString`` + func toString() -> String { + return "[HybridObject Compressor]" + } +} + +/// See ``HybridCompressorSpec`` +open class HybridCompressorSpec_base { + private weak var cxxWrapper: HybridCompressorSpec_cxx? = nil + public init() { } + public func getCxxWrapper() -> HybridCompressorSpec_cxx { + #if DEBUG + guard self is any HybridCompressorSpec else { + fatalError("`self` is not a `HybridCompressorSpec`! Did you accidentally inherit from `HybridCompressorSpec_base` instead of `HybridCompressorSpec`?") + } + #endif + if let cxxWrapper = self.cxxWrapper { + return cxxWrapper + } else { + let cxxWrapper = HybridCompressorSpec_cxx(self as! any HybridCompressorSpec) + self.cxxWrapper = cxxWrapper + return cxxWrapper + } + } +} + +/** + * A Swift base-protocol representing the Compressor HybridObject. + * Implement this protocol to create Swift-based instances of Compressor. + * ```swift + * class HybridCompressor : HybridCompressorSpec { + * // ... + * } + * ``` + */ +public typealias HybridCompressorSpec = HybridCompressorSpec_protocol & HybridCompressorSpec_base diff --git a/nitrogen/generated/ios/swift/HybridCompressorSpec_cxx.swift b/nitrogen/generated/ios/swift/HybridCompressorSpec_cxx.swift new file mode 100644 index 00000000..070fe99c --- /dev/null +++ b/nitrogen/generated/ios/swift/HybridCompressorSpec_cxx.swift @@ -0,0 +1,493 @@ +/// +/// HybridCompressorSpec_cxx.swift +/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +/// https://github.com/mrousavy/nitro +/// Copyright © Marc Rousavy @ Margelo +/// + +import NitroModules + +/** + * A class implementation that bridges HybridCompressorSpec over to C++. + * In C++, we cannot use Swift protocols - so we need to wrap it in a class to make it strongly defined. + * + * Also, some Swift types need to be bridged with special handling: + * - Enums need to be wrapped in Structs, otherwise they cannot be accessed bi-directionally (Swift bug: https://github.com/swiftlang/swift/issues/75330) + * - Other HybridObjects need to be wrapped/unwrapped from the Swift TCxx wrapper + * - Throwing methods need to be wrapped with a Result type, as exceptions cannot be propagated to C++ + */ +open class HybridCompressorSpec_cxx { + /** + * The Swift <> C++ bridge's namespace (`margelo::nitro::compressor::bridge::swift`) + * from `react_native_compressor-Swift-Cxx-Bridge.hpp`. + * This contains specialized C++ templates, and C++ helper functions that can be accessed from Swift. + */ + public typealias bridge = margelo.nitro.compressor.bridge.swift + + /** + * Holds an instance of the `HybridCompressorSpec` Swift protocol. + */ + private var __implementation: any HybridCompressorSpec + + /** + * Holds a weak pointer to the C++ class that wraps the Swift class. + */ + private var __cxxPart: bridge.std__weak_ptr_HybridCompressorSpec_ + + /** + * Create a new `HybridCompressorSpec_cxx` that wraps the given `HybridCompressorSpec`. + * All properties and methods bridge to C++ types. + */ + public init(_ implementation: any HybridCompressorSpec) { + self.__implementation = implementation + self.__cxxPart = .init() + /* no base class */ + } + + /** + * Get the actual `HybridCompressorSpec` instance this class wraps. + */ + @inline(__always) + public func getHybridCompressorSpec() -> any HybridCompressorSpec { + return __implementation + } + + /** + * Casts this instance to a retained unsafe raw pointer. + * This acquires one additional strong reference on the object! + */ + public func toUnsafe() -> UnsafeMutableRawPointer { + return Unmanaged.passRetained(self).toOpaque() + } + + /** + * Casts an unsafe pointer to a `HybridCompressorSpec_cxx`. + * The pointer has to be a retained opaque `Unmanaged`. + * This removes one strong reference from the object! + */ + public class func fromUnsafe(_ pointer: UnsafeMutableRawPointer) -> HybridCompressorSpec_cxx { + return Unmanaged.fromOpaque(pointer).takeRetainedValue() + } + + /** + * Gets (or creates) the C++ part of this Hybrid Object. + * The C++ part is a `std::shared_ptr`. + */ + public func getCxxPart() -> bridge.std__shared_ptr_HybridCompressorSpec_ { + let cachedCxxPart = self.__cxxPart.lock() + if Bool(fromCxx: cachedCxxPart) { + return cachedCxxPart + } else { + let newCxxPart = bridge.create_std__shared_ptr_HybridCompressorSpec_(self.toUnsafe()) + __cxxPart = bridge.weakify_std__shared_ptr_HybridCompressorSpec_(newCxxPart) + return newCxxPart + } + } + + + + /** + * Get the memory size of the Swift class (plus size of any other allocations) + * so the JS VM can properly track it and garbage-collect the JS object if needed. + */ + @inline(__always) + public var memorySize: Int { + return MemoryHelper.getSizeOf(self.__implementation) + self.__implementation.memorySize + } + + /** + * Compares this object with the given [other] object for reference equality. + */ + @inline(__always) + public func equals(other: HybridCompressorSpec_cxx) -> Bool { + return self.__implementation === other.__implementation + } + + /** + * Call dispose() on the Swift class. + * This _may_ be called manually from JS. + */ + @inline(__always) + public func dispose() { + self.__implementation.dispose() + } + + /** + * Call toString() on the Swift class. + */ + @inline(__always) + public func toString() -> String { + return self.__implementation.toString() + } + + // Properties + + + // Methods + @inline(__always) + public final func image_compress(imagePath: std.string, optionMap: margelo.nitro.SharedAnyMap, onDownloadProgress: bridge.std__optional_std__function_void_double____progress______) -> bridge.Result_std__shared_ptr_Promise_std__string___ { + do { + let __result = try self.__implementation.image_compress(imagePath: String(imagePath), optionMap: AnyMap(withCppPart: optionMap), onDownloadProgress: { () -> ((_ progress: Double) -> Void)? in + if bridge.has_value_std__optional_std__function_void_double____progress______(onDownloadProgress) { + let __unwrapped = bridge.get_std__optional_std__function_void_double____progress______(onDownloadProgress) + return { () -> (Double) -> Void in + let __wrappedFunction = bridge.wrap_Func_void_double(__unwrapped) + return { (__progress: Double) -> Void in + __wrappedFunction.call(__progress) + } + }() + } else { + return nil + } + }()) + let __resultCpp = { () -> bridge.std__shared_ptr_Promise_std__string__ in + let __promise = bridge.create_std__shared_ptr_Promise_std__string__() + let __promiseHolder = bridge.wrap_std__shared_ptr_Promise_std__string__(__promise) + __result + .then({ __result in __promiseHolder.resolve(std.string(__result)) }) + .catch({ __error in __promiseHolder.reject(__error.toCpp()) }) + return __promise + }() + return bridge.create_Result_std__shared_ptr_Promise_std__string___(__resultCpp) + } catch (let __error) { + let __exceptionPtr = __error.toCpp() + return bridge.create_Result_std__shared_ptr_Promise_std__string___(__exceptionPtr) + } + } + + @inline(__always) + public final func getImageMetaData(filePath: std.string) -> bridge.Result_std__shared_ptr_Promise_std__shared_ptr_AnyMap____ { + do { + let __result = try self.__implementation.getImageMetaData(filePath: String(filePath)) + let __resultCpp = { () -> bridge.std__shared_ptr_Promise_std__shared_ptr_AnyMap___ in + let __promise = bridge.create_std__shared_ptr_Promise_std__shared_ptr_AnyMap___() + let __promiseHolder = bridge.wrap_std__shared_ptr_Promise_std__shared_ptr_AnyMap___(__promise) + __result + .then({ __result in __promiseHolder.resolve(__result.cppPart) }) + .catch({ __error in __promiseHolder.reject(__error.toCpp()) }) + return __promise + }() + return bridge.create_Result_std__shared_ptr_Promise_std__shared_ptr_AnyMap____(__resultCpp) + } catch (let __error) { + let __exceptionPtr = __error.toCpp() + return bridge.create_Result_std__shared_ptr_Promise_std__shared_ptr_AnyMap____(__exceptionPtr) + } + } + + @inline(__always) + public final func compress(fileUrl: std.string, optionMap: margelo.nitro.SharedAnyMap, onProgress: bridge.std__optional_std__function_void_double____progress______, onDownloadProgress: bridge.std__optional_std__function_void_double____progress______) -> bridge.Result_std__shared_ptr_Promise_std__string___ { + do { + let __result = try self.__implementation.compress(fileUrl: String(fileUrl), optionMap: AnyMap(withCppPart: optionMap), onProgress: { () -> ((_ progress: Double) -> Void)? in + if bridge.has_value_std__optional_std__function_void_double____progress______(onProgress) { + let __unwrapped = bridge.get_std__optional_std__function_void_double____progress______(onProgress) + return { () -> (Double) -> Void in + let __wrappedFunction = bridge.wrap_Func_void_double(__unwrapped) + return { (__progress: Double) -> Void in + __wrappedFunction.call(__progress) + } + }() + } else { + return nil + } + }(), onDownloadProgress: { () -> ((_ progress: Double) -> Void)? in + if bridge.has_value_std__optional_std__function_void_double____progress______(onDownloadProgress) { + let __unwrapped = bridge.get_std__optional_std__function_void_double____progress______(onDownloadProgress) + return { () -> (Double) -> Void in + let __wrappedFunction = bridge.wrap_Func_void_double(__unwrapped) + return { (__progress: Double) -> Void in + __wrappedFunction.call(__progress) + } + }() + } else { + return nil + } + }()) + let __resultCpp = { () -> bridge.std__shared_ptr_Promise_std__string__ in + let __promise = bridge.create_std__shared_ptr_Promise_std__string__() + let __promiseHolder = bridge.wrap_std__shared_ptr_Promise_std__string__(__promise) + __result + .then({ __result in __promiseHolder.resolve(std.string(__result)) }) + .catch({ __error in __promiseHolder.reject(__error.toCpp()) }) + return __promise + }() + return bridge.create_Result_std__shared_ptr_Promise_std__string___(__resultCpp) + } catch (let __error) { + let __exceptionPtr = __error.toCpp() + return bridge.create_Result_std__shared_ptr_Promise_std__string___(__exceptionPtr) + } + } + + @inline(__always) + public final func cancelCompression(uuid: std.string) -> bridge.Result_void_ { + do { + try self.__implementation.cancelCompression(uuid: String(uuid)) + return bridge.create_Result_void_() + } catch (let __error) { + let __exceptionPtr = __error.toCpp() + return bridge.create_Result_void_(__exceptionPtr) + } + } + + @inline(__always) + public final func getVideoMetaData(filePath: std.string) -> bridge.Result_std__shared_ptr_Promise_std__shared_ptr_AnyMap____ { + do { + let __result = try self.__implementation.getVideoMetaData(filePath: String(filePath)) + let __resultCpp = { () -> bridge.std__shared_ptr_Promise_std__shared_ptr_AnyMap___ in + let __promise = bridge.create_std__shared_ptr_Promise_std__shared_ptr_AnyMap___() + let __promiseHolder = bridge.wrap_std__shared_ptr_Promise_std__shared_ptr_AnyMap___(__promise) + __result + .then({ __result in __promiseHolder.resolve(__result.cppPart) }) + .catch({ __error in __promiseHolder.reject(__error.toCpp()) }) + return __promise + }() + return bridge.create_Result_std__shared_ptr_Promise_std__shared_ptr_AnyMap____(__resultCpp) + } catch (let __error) { + let __exceptionPtr = __error.toCpp() + return bridge.create_Result_std__shared_ptr_Promise_std__shared_ptr_AnyMap____(__exceptionPtr) + } + } + + @inline(__always) + public final func activateBackgroundTask(options: margelo.nitro.SharedAnyMap, onExpired: bridge.std__optional_std__function_void____) -> bridge.Result_std__shared_ptr_Promise_std__string___ { + do { + let __result = try self.__implementation.activateBackgroundTask(options: AnyMap(withCppPart: options), onExpired: { () -> (() -> Void)? in + if bridge.has_value_std__optional_std__function_void____(onExpired) { + let __unwrapped = bridge.get_std__optional_std__function_void____(onExpired) + return { () -> () -> Void in + let __wrappedFunction = bridge.wrap_Func_void(__unwrapped) + return { () -> Void in + __wrappedFunction.call() + } + }() + } else { + return nil + } + }()) + let __resultCpp = { () -> bridge.std__shared_ptr_Promise_std__string__ in + let __promise = bridge.create_std__shared_ptr_Promise_std__string__() + let __promiseHolder = bridge.wrap_std__shared_ptr_Promise_std__string__(__promise) + __result + .then({ __result in __promiseHolder.resolve(std.string(__result)) }) + .catch({ __error in __promiseHolder.reject(__error.toCpp()) }) + return __promise + }() + return bridge.create_Result_std__shared_ptr_Promise_std__string___(__resultCpp) + } catch (let __error) { + let __exceptionPtr = __error.toCpp() + return bridge.create_Result_std__shared_ptr_Promise_std__string___(__exceptionPtr) + } + } + + @inline(__always) + public final func deactivateBackgroundTask(options: margelo.nitro.SharedAnyMap) -> bridge.Result_std__shared_ptr_Promise_std__string___ { + do { + let __result = try self.__implementation.deactivateBackgroundTask(options: AnyMap(withCppPart: options)) + let __resultCpp = { () -> bridge.std__shared_ptr_Promise_std__string__ in + let __promise = bridge.create_std__shared_ptr_Promise_std__string__() + let __promiseHolder = bridge.wrap_std__shared_ptr_Promise_std__string__(__promise) + __result + .then({ __result in __promiseHolder.resolve(std.string(__result)) }) + .catch({ __error in __promiseHolder.reject(__error.toCpp()) }) + return __promise + }() + return bridge.create_Result_std__shared_ptr_Promise_std__string___(__resultCpp) + } catch (let __error) { + let __exceptionPtr = __error.toCpp() + return bridge.create_Result_std__shared_ptr_Promise_std__string___(__exceptionPtr) + } + } + + @inline(__always) + public final func compress_audio(fileUrl: std.string, optionMap: margelo.nitro.SharedAnyMap) -> bridge.Result_std__shared_ptr_Promise_std__string___ { + do { + let __result = try self.__implementation.compress_audio(fileUrl: String(fileUrl), optionMap: AnyMap(withCppPart: optionMap)) + let __resultCpp = { () -> bridge.std__shared_ptr_Promise_std__string__ in + let __promise = bridge.create_std__shared_ptr_Promise_std__string__() + let __promiseHolder = bridge.wrap_std__shared_ptr_Promise_std__string__(__promise) + __result + .then({ __result in __promiseHolder.resolve(std.string(__result)) }) + .catch({ __error in __promiseHolder.reject(__error.toCpp()) }) + return __promise + }() + return bridge.create_Result_std__shared_ptr_Promise_std__string___(__resultCpp) + } catch (let __error) { + let __exceptionPtr = __error.toCpp() + return bridge.create_Result_std__shared_ptr_Promise_std__string___(__exceptionPtr) + } + } + + @inline(__always) + public final func upload(fileUrl: std.string, options: margelo.nitro.SharedAnyMap, onProgress: bridge.std__optional_std__function_void_double____written_____double____total______) -> bridge.Result_std__shared_ptr_Promise_std__shared_ptr_AnyMap____ { + do { + let __result = try self.__implementation.upload(fileUrl: String(fileUrl), options: AnyMap(withCppPart: options), onProgress: { () -> ((_ written: Double, _ total: Double) -> Void)? in + if bridge.has_value_std__optional_std__function_void_double____written_____double____total______(onProgress) { + let __unwrapped = bridge.get_std__optional_std__function_void_double____written_____double____total______(onProgress) + return { () -> (Double, Double) -> Void in + let __wrappedFunction = bridge.wrap_Func_void_double_double(__unwrapped) + return { (__written: Double, __total: Double) -> Void in + __wrappedFunction.call(__written, __total) + } + }() + } else { + return nil + } + }()) + let __resultCpp = { () -> bridge.std__shared_ptr_Promise_std__shared_ptr_AnyMap___ in + let __promise = bridge.create_std__shared_ptr_Promise_std__shared_ptr_AnyMap___() + let __promiseHolder = bridge.wrap_std__shared_ptr_Promise_std__shared_ptr_AnyMap___(__promise) + __result + .then({ __result in __promiseHolder.resolve(__result.cppPart) }) + .catch({ __error in __promiseHolder.reject(__error.toCpp()) }) + return __promise + }() + return bridge.create_Result_std__shared_ptr_Promise_std__shared_ptr_AnyMap____(__resultCpp) + } catch (let __error) { + let __exceptionPtr = __error.toCpp() + return bridge.create_Result_std__shared_ptr_Promise_std__shared_ptr_AnyMap____(__exceptionPtr) + } + } + + @inline(__always) + public final func cancelUpload(uuid: std.string, shouldCancelAll: Bool) -> bridge.Result_void_ { + do { + try self.__implementation.cancelUpload(uuid: String(uuid), shouldCancelAll: shouldCancelAll) + return bridge.create_Result_void_() + } catch (let __error) { + let __exceptionPtr = __error.toCpp() + return bridge.create_Result_void_(__exceptionPtr) + } + } + + @inline(__always) + public final func download(fileUrl: std.string, options: margelo.nitro.SharedAnyMap, onProgress: bridge.std__optional_std__function_void_double____progress______) -> bridge.Result_std__shared_ptr_Promise_std__string___ { + do { + let __result = try self.__implementation.download(fileUrl: String(fileUrl), options: AnyMap(withCppPart: options), onProgress: { () -> ((_ progress: Double) -> Void)? in + if bridge.has_value_std__optional_std__function_void_double____progress______(onProgress) { + let __unwrapped = bridge.get_std__optional_std__function_void_double____progress______(onProgress) + return { () -> (Double) -> Void in + let __wrappedFunction = bridge.wrap_Func_void_double(__unwrapped) + return { (__progress: Double) -> Void in + __wrappedFunction.call(__progress) + } + }() + } else { + return nil + } + }()) + let __resultCpp = { () -> bridge.std__shared_ptr_Promise_std__string__ in + let __promise = bridge.create_std__shared_ptr_Promise_std__string__() + let __promiseHolder = bridge.wrap_std__shared_ptr_Promise_std__string__(__promise) + __result + .then({ __result in __promiseHolder.resolve(std.string(__result)) }) + .catch({ __error in __promiseHolder.reject(__error.toCpp()) }) + return __promise + }() + return bridge.create_Result_std__shared_ptr_Promise_std__string___(__resultCpp) + } catch (let __error) { + let __exceptionPtr = __error.toCpp() + return bridge.create_Result_std__shared_ptr_Promise_std__string___(__exceptionPtr) + } + } + + @inline(__always) + public final func generateFilePath(fileExtension: std.string) -> bridge.Result_std__shared_ptr_Promise_std__string___ { + do { + let __result = try self.__implementation.generateFilePath(fileExtension: String(fileExtension)) + let __resultCpp = { () -> bridge.std__shared_ptr_Promise_std__string__ in + let __promise = bridge.create_std__shared_ptr_Promise_std__string__() + let __promiseHolder = bridge.wrap_std__shared_ptr_Promise_std__string__(__promise) + __result + .then({ __result in __promiseHolder.resolve(std.string(__result)) }) + .catch({ __error in __promiseHolder.reject(__error.toCpp()) }) + return __promise + }() + return bridge.create_Result_std__shared_ptr_Promise_std__string___(__resultCpp) + } catch (let __error) { + let __exceptionPtr = __error.toCpp() + return bridge.create_Result_std__shared_ptr_Promise_std__string___(__exceptionPtr) + } + } + + @inline(__always) + public final func getRealPath(path: std.string, type: std.string) -> bridge.Result_std__shared_ptr_Promise_std__string___ { + do { + let __result = try self.__implementation.getRealPath(path: String(path), type: String(type)) + let __resultCpp = { () -> bridge.std__shared_ptr_Promise_std__string__ in + let __promise = bridge.create_std__shared_ptr_Promise_std__string__() + let __promiseHolder = bridge.wrap_std__shared_ptr_Promise_std__string__(__promise) + __result + .then({ __result in __promiseHolder.resolve(std.string(__result)) }) + .catch({ __error in __promiseHolder.reject(__error.toCpp()) }) + return __promise + }() + return bridge.create_Result_std__shared_ptr_Promise_std__string___(__resultCpp) + } catch (let __error) { + let __exceptionPtr = __error.toCpp() + return bridge.create_Result_std__shared_ptr_Promise_std__string___(__exceptionPtr) + } + } + + @inline(__always) + public final func getFileSize(filePath: std.string) -> bridge.Result_std__shared_ptr_Promise_std__string___ { + do { + let __result = try self.__implementation.getFileSize(filePath: String(filePath)) + let __resultCpp = { () -> bridge.std__shared_ptr_Promise_std__string__ in + let __promise = bridge.create_std__shared_ptr_Promise_std__string__() + let __promiseHolder = bridge.wrap_std__shared_ptr_Promise_std__string__(__promise) + __result + .then({ __result in __promiseHolder.resolve(std.string(__result)) }) + .catch({ __error in __promiseHolder.reject(__error.toCpp()) }) + return __promise + }() + return bridge.create_Result_std__shared_ptr_Promise_std__string___(__resultCpp) + } catch (let __error) { + let __exceptionPtr = __error.toCpp() + return bridge.create_Result_std__shared_ptr_Promise_std__string___(__exceptionPtr) + } + } + + @inline(__always) + public final func createVideoThumbnail(fileUrl: std.string, options: margelo.nitro.SharedAnyMap) -> bridge.Result_std__shared_ptr_Promise_VideoThumbnailResult___ { + do { + let __result = try self.__implementation.createVideoThumbnail(fileUrl: String(fileUrl), options: AnyMap(withCppPart: options)) + let __resultCpp = { () -> bridge.std__shared_ptr_Promise_VideoThumbnailResult__ in + let __promise = bridge.create_std__shared_ptr_Promise_VideoThumbnailResult__() + let __promiseHolder = bridge.wrap_std__shared_ptr_Promise_VideoThumbnailResult__(__promise) + __result + .then({ __result in __promiseHolder.resolve(__result) }) + .catch({ __error in __promiseHolder.reject(__error.toCpp()) }) + return __promise + }() + return bridge.create_Result_std__shared_ptr_Promise_VideoThumbnailResult___(__resultCpp) + } catch (let __error) { + let __exceptionPtr = __error.toCpp() + return bridge.create_Result_std__shared_ptr_Promise_VideoThumbnailResult___(__exceptionPtr) + } + } + + @inline(__always) + public final func clearCache(cacheDir: bridge.std__optional_std__string_) -> bridge.Result_std__shared_ptr_Promise_std__string___ { + do { + let __result = try self.__implementation.clearCache(cacheDir: { () -> String? in + if bridge.has_value_std__optional_std__string_(cacheDir) { + let __unwrapped = bridge.get_std__optional_std__string_(cacheDir) + return String(__unwrapped) + } else { + return nil + } + }()) + let __resultCpp = { () -> bridge.std__shared_ptr_Promise_std__string__ in + let __promise = bridge.create_std__shared_ptr_Promise_std__string__() + let __promiseHolder = bridge.wrap_std__shared_ptr_Promise_std__string__(__promise) + __result + .then({ __result in __promiseHolder.resolve(std.string(__result)) }) + .catch({ __error in __promiseHolder.reject(__error.toCpp()) }) + return __promise + }() + return bridge.create_Result_std__shared_ptr_Promise_std__string___(__resultCpp) + } catch (let __error) { + let __exceptionPtr = __error.toCpp() + return bridge.create_Result_std__shared_ptr_Promise_std__string___(__exceptionPtr) + } + } +} diff --git a/nitrogen/generated/ios/swift/VideoThumbnailResult.swift b/nitrogen/generated/ios/swift/VideoThumbnailResult.swift new file mode 100644 index 00000000..b20832c6 --- /dev/null +++ b/nitrogen/generated/ios/swift/VideoThumbnailResult.swift @@ -0,0 +1,49 @@ +/// +/// VideoThumbnailResult.swift +/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +/// https://github.com/mrousavy/nitro +/// Copyright © Marc Rousavy @ Margelo +/// + +import NitroModules + +/** + * Represents an instance of `VideoThumbnailResult`, backed by a C++ struct. + */ +public typealias VideoThumbnailResult = margelo.nitro.compressor.VideoThumbnailResult + +public extension VideoThumbnailResult { + private typealias bridge = margelo.nitro.compressor.bridge.swift + + /** + * Create a new instance of `VideoThumbnailResult`. + */ + init(path: String, size: Double, mime: String, width: Double, height: Double) { + self.init(std.string(path), size, std.string(mime), width, height) + } + + @inline(__always) + var path: String { + return String(self.__path) + } + + @inline(__always) + var size: Double { + return self.__size + } + + @inline(__always) + var mime: String { + return String(self.__mime) + } + + @inline(__always) + var width: Double { + return self.__width + } + + @inline(__always) + var height: Double { + return self.__height + } +} diff --git a/nitrogen/generated/shared/c++/HybridCompressorSpec.cpp b/nitrogen/generated/shared/c++/HybridCompressorSpec.cpp new file mode 100644 index 00000000..5a6cf931 --- /dev/null +++ b/nitrogen/generated/shared/c++/HybridCompressorSpec.cpp @@ -0,0 +1,36 @@ +/// +/// HybridCompressorSpec.cpp +/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +/// https://github.com/mrousavy/nitro +/// Copyright © Marc Rousavy @ Margelo +/// + +#include "HybridCompressorSpec.hpp" + +namespace margelo::nitro::compressor { + + void HybridCompressorSpec::loadHybridMethods() { + // load base methods/properties + HybridObject::loadHybridMethods(); + // load custom methods/properties + registerHybrids(this, [](Prototype& prototype) { + prototype.registerHybridMethod("image_compress", &HybridCompressorSpec::image_compress); + prototype.registerHybridMethod("getImageMetaData", &HybridCompressorSpec::getImageMetaData); + prototype.registerHybridMethod("compress", &HybridCompressorSpec::compress); + prototype.registerHybridMethod("cancelCompression", &HybridCompressorSpec::cancelCompression); + prototype.registerHybridMethod("getVideoMetaData", &HybridCompressorSpec::getVideoMetaData); + prototype.registerHybridMethod("activateBackgroundTask", &HybridCompressorSpec::activateBackgroundTask); + prototype.registerHybridMethod("deactivateBackgroundTask", &HybridCompressorSpec::deactivateBackgroundTask); + prototype.registerHybridMethod("compress_audio", &HybridCompressorSpec::compress_audio); + prototype.registerHybridMethod("upload", &HybridCompressorSpec::upload); + prototype.registerHybridMethod("cancelUpload", &HybridCompressorSpec::cancelUpload); + prototype.registerHybridMethod("download", &HybridCompressorSpec::download); + prototype.registerHybridMethod("generateFilePath", &HybridCompressorSpec::generateFilePath); + prototype.registerHybridMethod("getRealPath", &HybridCompressorSpec::getRealPath); + prototype.registerHybridMethod("getFileSize", &HybridCompressorSpec::getFileSize); + prototype.registerHybridMethod("createVideoThumbnail", &HybridCompressorSpec::createVideoThumbnail); + prototype.registerHybridMethod("clearCache", &HybridCompressorSpec::clearCache); + }); + } + +} // namespace margelo::nitro::compressor diff --git a/nitrogen/generated/shared/c++/HybridCompressorSpec.hpp b/nitrogen/generated/shared/c++/HybridCompressorSpec.hpp new file mode 100644 index 00000000..b7029b7f --- /dev/null +++ b/nitrogen/generated/shared/c++/HybridCompressorSpec.hpp @@ -0,0 +1,83 @@ +/// +/// HybridCompressorSpec.hpp +/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +/// https://github.com/mrousavy/nitro +/// Copyright © Marc Rousavy @ Margelo +/// + +#pragma once + +#if __has_include() +#include +#else +#error NitroModules cannot be found! Are you sure you installed NitroModules properly? +#endif + +// Forward declaration of `VideoThumbnailResult` to properly resolve imports. +namespace margelo::nitro::compressor { struct VideoThumbnailResult; } + +#include +#include +#include +#include +#include +#include "VideoThumbnailResult.hpp" + +namespace margelo::nitro::compressor { + + using namespace margelo::nitro; + + /** + * An abstract base class for `Compressor` + * Inherit this class to create instances of `HybridCompressorSpec` in C++. + * You must explicitly call `HybridObject`'s constructor yourself, because it is virtual. + * @example + * ```cpp + * class HybridCompressor: public HybridCompressorSpec { + * public: + * HybridCompressor(...): HybridObject(TAG) { ... } + * // ... + * }; + * ``` + */ + class HybridCompressorSpec: public virtual HybridObject { + public: + // Constructor + explicit HybridCompressorSpec(): HybridObject(TAG) { } + + // Destructor + ~HybridCompressorSpec() override = default; + + public: + // Properties + + + public: + // Methods + virtual std::shared_ptr> image_compress(const std::string& imagePath, const std::shared_ptr& optionMap, const std::optional>& onDownloadProgress) = 0; + virtual std::shared_ptr>> getImageMetaData(const std::string& filePath) = 0; + virtual std::shared_ptr> compress(const std::string& fileUrl, const std::shared_ptr& optionMap, const std::optional>& onProgress, const std::optional>& onDownloadProgress) = 0; + virtual void cancelCompression(const std::string& uuid) = 0; + virtual std::shared_ptr>> getVideoMetaData(const std::string& filePath) = 0; + virtual std::shared_ptr> activateBackgroundTask(const std::shared_ptr& options, const std::optional>& onExpired) = 0; + virtual std::shared_ptr> deactivateBackgroundTask(const std::shared_ptr& options) = 0; + virtual std::shared_ptr> compress_audio(const std::string& fileUrl, const std::shared_ptr& optionMap) = 0; + virtual std::shared_ptr>> upload(const std::string& fileUrl, const std::shared_ptr& options, const std::optional>& onProgress) = 0; + virtual void cancelUpload(const std::string& uuid, bool shouldCancelAll) = 0; + virtual std::shared_ptr> download(const std::string& fileUrl, const std::shared_ptr& options, const std::optional>& onProgress) = 0; + virtual std::shared_ptr> generateFilePath(const std::string& fileExtension) = 0; + virtual std::shared_ptr> getRealPath(const std::string& path, const std::string& type) = 0; + virtual std::shared_ptr> getFileSize(const std::string& filePath) = 0; + virtual std::shared_ptr> createVideoThumbnail(const std::string& fileUrl, const std::shared_ptr& options) = 0; + virtual std::shared_ptr> clearCache(const std::optional& cacheDir) = 0; + + protected: + // Hybrid Setup + void loadHybridMethods() override; + + protected: + // Tag for logging + static constexpr auto TAG = "Compressor"; + }; + +} // namespace margelo::nitro::compressor diff --git a/nitrogen/generated/shared/c++/VideoThumbnailResult.hpp b/nitrogen/generated/shared/c++/VideoThumbnailResult.hpp new file mode 100644 index 00000000..43dd3481 --- /dev/null +++ b/nitrogen/generated/shared/c++/VideoThumbnailResult.hpp @@ -0,0 +1,99 @@ +/// +/// VideoThumbnailResult.hpp +/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +/// https://github.com/mrousavy/nitro +/// Copyright © Marc Rousavy @ Margelo +/// + +#pragma once + +#if __has_include() +#include +#else +#error NitroModules cannot be found! Are you sure you installed NitroModules properly? +#endif +#if __has_include() +#include +#else +#error NitroModules cannot be found! Are you sure you installed NitroModules properly? +#endif +#if __has_include() +#include +#else +#error NitroModules cannot be found! Are you sure you installed NitroModules properly? +#endif +#if __has_include() +#include +#else +#error NitroModules cannot be found! Are you sure you installed NitroModules properly? +#endif + + + +#include + +namespace margelo::nitro::compressor { + + /** + * A struct which can be represented as a JavaScript object (VideoThumbnailResult). + */ + struct VideoThumbnailResult final { + public: + std::string path SWIFT_PRIVATE; + double size SWIFT_PRIVATE; + std::string mime SWIFT_PRIVATE; + double width SWIFT_PRIVATE; + double height SWIFT_PRIVATE; + + public: + VideoThumbnailResult() = default; + explicit VideoThumbnailResult(std::string path, double size, std::string mime, double width, double height): path(path), size(size), mime(mime), width(width), height(height) {} + + public: + friend bool operator==(const VideoThumbnailResult& lhs, const VideoThumbnailResult& rhs) = default; + }; + +} // namespace margelo::nitro::compressor + +namespace margelo::nitro { + + // C++ VideoThumbnailResult <> JS VideoThumbnailResult (object) + template <> + struct JSIConverter final { + static inline margelo::nitro::compressor::VideoThumbnailResult fromJSI(jsi::Runtime& runtime, const jsi::Value& arg) { + jsi::Object obj = arg.asObject(runtime); + return margelo::nitro::compressor::VideoThumbnailResult( + JSIConverter::fromJSI(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "path"))), + JSIConverter::fromJSI(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "size"))), + JSIConverter::fromJSI(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "mime"))), + JSIConverter::fromJSI(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "width"))), + JSIConverter::fromJSI(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "height"))) + ); + } + static inline jsi::Value toJSI(jsi::Runtime& runtime, const margelo::nitro::compressor::VideoThumbnailResult& arg) { + jsi::Object obj(runtime); + obj.setProperty(runtime, PropNameIDCache::get(runtime, "path"), JSIConverter::toJSI(runtime, arg.path)); + obj.setProperty(runtime, PropNameIDCache::get(runtime, "size"), JSIConverter::toJSI(runtime, arg.size)); + obj.setProperty(runtime, PropNameIDCache::get(runtime, "mime"), JSIConverter::toJSI(runtime, arg.mime)); + obj.setProperty(runtime, PropNameIDCache::get(runtime, "width"), JSIConverter::toJSI(runtime, arg.width)); + obj.setProperty(runtime, PropNameIDCache::get(runtime, "height"), JSIConverter::toJSI(runtime, arg.height)); + return obj; + } + static inline bool canConvert(jsi::Runtime& runtime, const jsi::Value& value) { + if (!value.isObject()) { + return false; + } + jsi::Object obj = value.getObject(runtime); + if (!nitro::isPlainObject(runtime, obj)) { + return false; + } + if (!JSIConverter::canConvert(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "path")))) return false; + if (!JSIConverter::canConvert(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "size")))) return false; + if (!JSIConverter::canConvert(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "mime")))) return false; + if (!JSIConverter::canConvert(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "width")))) return false; + if (!JSIConverter::canConvert(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "height")))) return false; + return true; + } + }; + +} // namespace margelo::nitro diff --git a/package.json b/package.json index e0db75a9..c5483525 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "android", "ios", "cpp", + "nitrogen", "app.plugin.js", "*.podspec", "!lib/typescript/example", @@ -34,7 +35,8 @@ "test:harness:ios": "cd examples/bare && react-native-harness --config ./jest.harness.config.mjs --harnessRunner ios --verbose", "typecheck": "tsc --noEmit", "lint": "eslint \"**/*.{js,ts,tsx}\"", - "prepack": "bob build", + "nitrogen": "nitrogen", + "prepack": "nitrogen && bob build", "release": "release-it", "example:bare": "yarn workspace react-native-compressor-bare-example", "example:expo": "yarn workspace react-native-compressor-expo-example", @@ -106,18 +108,21 @@ "eslint-plugin-prettier": "^5.5.5", "jest": "^30.3.0", "lefthook": "^2.1.4", + "nitrogen": "^0.35.9", "prettier": "^3.8.1", "react": "19.2.3", "react-native": "0.85.0", "react-native-builder-bob": "^0.41.0", "react-native-harness": "1.1.0", + "react-native-nitro-modules": "^0.35.9", "release-it": "^19.2.4", "turbo": "^2.8.21", "typescript": "^6.0.2" }, "peerDependencies": { "react": "*", - "react-native": "*" + "react-native": "*", + "react-native-nitro-modules": ">=0.35.0" }, "engines": { "node": ">= 22.11.0" @@ -136,15 +141,6 @@ ] ] }, - "codegenConfig": { - "libraries": [ - { - "name": "RNCompressorSpec", - "type": "modules", - "jsSrcsDir": "src" - } - ] - }, "workspaces": [ "examples/*" ], diff --git a/react-native-compressor.podspec b/react-native-compressor.podspec index 921e7399..ac3cd711 100644 --- a/react-native-compressor.podspec +++ b/react-native-compressor.podspec @@ -1,7 +1,6 @@ require "json" package = JSON.parse(File.read(File.join(__dir__, "package.json"))) -folly_compiler_flags = '-DFOLLY_NO_CONFIG -DFOLLY_MOBILE=1 -DFOLLY_USE_LIBCPP=1 -Wno-comma -Wno-shorten-64-to-32' Pod::Spec.new do |s| s.name = "react-native-compressor" @@ -11,32 +10,15 @@ Pod::Spec.new do |s| s.license = package["license"] s.authors = package["author"] - s.platforms = { :ios => "11.0" } + s.platforms = { :ios => "13.4" } s.source = { :git => "https://github.com/numandev1/react-native-compressor.git", :tag => "#{s.version}" } s.source_files = "ios/**/*.{h,m,mm,swift}" - s.dependency "React-Core" - # Use install_modules_dependencies helper to install the dependencies if React Native version >=0.71.0. - # See https://github.com/facebook/react-native/blob/febf6b7f33fdb4904669f99d795eba4c0f95d7bf/scripts/cocoapods/new_architecture.rb#L79. - if respond_to?(:install_modules_dependencies, true) - install_modules_dependencies(s) - else - s.dependency "React-Core" + # Add all files generated by Nitrogen + the NitroModules dependency. + load File.join(__dir__, 'nitrogen', 'generated', 'ios', 'react_native_compressor+autolinking.rb') + add_nitrogen_files(s) - # Don't install the dependencies when we run `pod install` in the old architecture. - if ENV['RCT_NEW_ARCH_ENABLED'] == '1' then - s.compiler_flags = folly_compiler_flags + " -DRCT_NEW_ARCH_ENABLED=1" - s.pod_target_xcconfig = { - "HEADER_SEARCH_PATHS" => "\"$(PODS_ROOT)/boost\"", - "OTHER_CPLUSPLUSFLAGS" => "-DFOLLY_NO_CONFIG -DFOLLY_MOBILE=1 -DFOLLY_USE_LIBCPP=1", - "CLANG_CXX_LANGUAGE_STANDARD" => "c++17" - } - s.dependency "React-Codegen" - s.dependency "RCT-Folly" - s.dependency "RCTRequired" - s.dependency "RCTTypeSafety" - s.dependency "ReactCommon/turbomodule/core" - end - end + s.dependency "React-Core" + install_modules_dependencies(s) end diff --git a/src/Audio/index.tsx b/src/Audio/index.tsx index e2cf0312..7a1dbcce 100644 --- a/src/Audio/index.tsx +++ b/src/Audio/index.tsx @@ -1,13 +1,13 @@ import { Compressor } from '../Main'; -import { DEFAULT_COMPRESS_AUDIO_OPTIONS } from '../utils'; +import { DEFAULT_COMPRESS_AUDIO_OPTIONS, toNativeOptions } from '../utils'; import type { AudioType } from '../utils'; const NativeAudio = Compressor; const Audio: AudioType = { compress: async (url, options = DEFAULT_COMPRESS_AUDIO_OPTIONS) => { try { - return NativeAudio.compress_audio(url, options); + return NativeAudio.compress_audio(url, toNativeOptions(options)); } catch (error: any) { throw error.message; } diff --git a/src/Image/index.tsx b/src/Image/index.tsx index 53ab2f37..09bcd35c 100644 --- a/src/Image/index.tsx +++ b/src/Image/index.tsx @@ -1,8 +1,6 @@ import { Compressor } from '../Main'; -import { uuidv4 } from '../utils'; +import { toNativeOptions } from '../utils'; const base64UrlRegex = /^data:image\/.*;(?:charset=.{3,5};)?base64,/; -import type { NativeEventSubscription } from 'react-native'; -import { NativeEventEmitter } from 'react-native'; export type InputType = 'base64' | 'uri'; @@ -55,8 +53,6 @@ export type CompressorOptions = { progressDivider?: number; }; -const ImageCompressEventEmitter = new NativeEventEmitter(Compressor); - const NativeImage = Compressor; type ImageType = { @@ -69,27 +65,8 @@ const Image: ImageType = { throw new Error('Compression value is empty, please provide a value for compression.'); } - let subscription: NativeEventSubscription; - try { - if (options?.downloadProgress) { - const uuid = uuidv4(); - //@ts-ignore - options.uuid = uuid; - subscription = ImageCompressEventEmitter.addListener('downloadProgress', (event: any) => { - if (event.uuid === uuid) { - options.downloadProgress && options.downloadProgress(event.data.progress); - } - }); - } - - const cleanData = value.replace(base64UrlRegex, ''); - return await NativeImage.image_compress(cleanData, options); - } finally { - // @ts-ignore - if (subscription) { - subscription.remove(); - } - } + const cleanData = value.replace(base64UrlRegex, ''); + return await NativeImage.image_compress(cleanData, toNativeOptions(options), options.downloadProgress); }, }; diff --git a/src/Main.tsx b/src/Main.tsx index f881323c..2eb5c48b 100644 --- a/src/Main.tsx +++ b/src/Main.tsx @@ -1,25 +1,23 @@ -import { NativeModules, Platform } from 'react-native'; +import { Platform } from 'react-native'; +import { NitroModules } from 'react-native-nitro-modules'; + +import type { Compressor as CompressorSpec } from './specs/Compressor.nitro'; const LINKING_ERROR = `The package 'react-native-compressor' doesn't seem to be linked. Make sure: \n\n` + Platform.select({ ios: "- You have run 'pod install'\n", default: '' }) + '- You rebuilt the app after installing the package\n' + - '- You are not using Expo Go\n'; - -// @ts-expect-error -const isTurboModuleEnabled = global.__turboModuleProxy != null; + '- You are not using Expo Go\n' + + '- You have installed `react-native-nitro-modules`\n'; -const CompressorModule = isTurboModuleEnabled ? require('./Spec/NativeCompressor').default : NativeModules.Compressor; +const createCompressor = (): CompressorSpec => { + try { + return NitroModules.createHybridObject('Compressor'); + } catch { + throw new Error(LINKING_ERROR); + } +}; -const Compressor = CompressorModule - ? CompressorModule - : new Proxy( - {}, - { - get() { - throw new Error(LINKING_ERROR); - }, - }, - ); +const Compressor = createCompressor(); export { Compressor }; diff --git a/src/Spec/NativeCompressor.ts b/src/Spec/NativeCompressor.ts deleted file mode 100644 index 8684414e..00000000 --- a/src/Spec/NativeCompressor.ts +++ /dev/null @@ -1,41 +0,0 @@ -import type { TurboModule } from 'react-native'; -import { TurboModuleRegistry } from 'react-native'; - -export interface Spec extends TurboModule { - // Image - image_compress(imagePath: string, optionMap: Object): Promise; - getImageMetaData(filePath: string): Promise; - // Video - compress(fileUrl: string, optionMap: Object): Promise; - cancelCompression(uuid: string): void; - getVideoMetaData(filePath: string): Promise; - activateBackgroundTask(options: Object): Promise; - deactivateBackgroundTask(options: Object): Promise; - //Audio - compress_audio(fileUrl: string, optionMap: Object): Promise; - // Upload - upload(fileUrl: string, options: Object): Promise; - // Cancel upload - cancelUpload(uuid: string, shouldCancelAll: boolean): void; - // Download - download(fileUrl: string, options: Object): Promise; - // Others - generateFilePath(_extension: string): Promise; - getRealPath(path: string, type: string): Promise; - getFileSize(filePath: string): Promise; - addListener(eventName: string): void; - removeListeners(count: number): void; - createVideoThumbnail( - fileUrl: string, - options: Object, - ): Promise<{ - path: string; - size: number; - mime: string; - width: number; - height: number; - }>; - clearCache(cacheDir: string | null): Promise; -} - -export default TurboModuleRegistry.getEnforcing('Compressor'); diff --git a/src/Video/index.tsx b/src/Video/index.tsx index 7ec49284..563a0157 100644 --- a/src/Video/index.tsx +++ b/src/Video/index.tsx @@ -1,7 +1,5 @@ -import { NativeEventEmitter } from 'react-native'; -import type { NativeEventSubscription } from 'react-native'; import { Compressor } from '../Main'; -import { uuidv4 } from '../utils'; +import { toNativeOptions, uuidv4 } from '../utils'; export type compressionMethod = 'auto' | 'manual'; type videoCompresssionType = { @@ -29,8 +27,6 @@ export type VideoCompressorType = { deactivateBackgroundTask(): Promise; }; -const VideoCompressEventEmitter = new NativeEventEmitter(Compressor); - const NativeVideoCompressor = Compressor; export const cancelCompression = (cancellationId: string) => { @@ -40,85 +36,37 @@ export const cancelCompression = (cancellationId: string) => { const Video: VideoCompressorType = { compress: async (fileUrl: string, options?: videoCompresssionType, onProgress?: (progress: number) => void) => { const uuid = uuidv4(); - let subscription: NativeEventSubscription; - let subscription2: NativeEventSubscription; - - try { - if (onProgress) { - subscription = VideoCompressEventEmitter.addListener('videoCompressProgress', (event: any) => { - if (event.uuid === uuid) { - onProgress(event.data.progress); - } - }); - } - - if (options?.downloadProgress) { - //@ts-ignore - subscription2 = VideoCompressEventEmitter.addListener('downloadProgress', (event: any) => { - if (event.uuid === uuid) { - options.downloadProgress && options.downloadProgress(event.data.progress); - } - }); - } - const modifiedOptions: { - uuid: string; - bitrate?: number; - compressionMethod?: compressionMethod; - maxSize?: number; - minimumFileSizeForCompress?: number; - progressDivider?: number; - stripAudio?: boolean; - } = { uuid }; - if (options?.progressDivider) modifiedOptions.progressDivider = options?.progressDivider; - if (options?.bitrate) modifiedOptions.bitrate = options?.bitrate; - if (options?.compressionMethod) { - modifiedOptions.compressionMethod = options?.compressionMethod; - } else { - modifiedOptions.compressionMethod = 'auto'; - } - if (options?.maxSize) { - modifiedOptions.maxSize = options?.maxSize; - } else { - modifiedOptions.maxSize = 640; - } - if (options?.minimumFileSizeForCompress !== undefined) { - modifiedOptions.minimumFileSizeForCompress = options?.minimumFileSizeForCompress; - } - if (options?.stripAudio) { - modifiedOptions.stripAudio = options.stripAudio; - } - if (options?.getCancellationId) { - options?.getCancellationId(uuid); - } - - const result = await NativeVideoCompressor.compress(fileUrl, modifiedOptions); - return result; - } finally { - // @ts-ignore - if (subscription) { - subscription.remove(); - } - //@ts-ignore - if (subscription2) { - subscription2.remove(); - } + const modifiedOptions: Record = { uuid }; + if (options?.progressDivider) modifiedOptions.progressDivider = options?.progressDivider; + if (options?.bitrate) modifiedOptions.bitrate = options?.bitrate; + if (options?.compressionMethod) { + modifiedOptions.compressionMethod = options?.compressionMethod; + } else { + modifiedOptions.compressionMethod = 'auto'; + } + if (options?.maxSize) { + modifiedOptions.maxSize = options?.maxSize; + } else { + modifiedOptions.maxSize = 640; } + if (options?.minimumFileSizeForCompress !== undefined) { + modifiedOptions.minimumFileSizeForCompress = options?.minimumFileSizeForCompress; + } + if (options?.stripAudio) { + modifiedOptions.stripAudio = options.stripAudio; + } + if (options?.getCancellationId) { + options?.getCancellationId(uuid); + } + + return NativeVideoCompressor.compress(fileUrl, toNativeOptions(modifiedOptions), onProgress, options?.downloadProgress); }, cancelCompression, activateBackgroundTask(onExpired?) { - if (onExpired) { - const subscription: NativeEventSubscription = VideoCompressEventEmitter.addListener('backgroundTaskExpired', (event: any) => { - onExpired(event); - if (subscription) { - subscription.remove(); - } - }); - } - return NativeVideoCompressor.activateBackgroundTask({}); + return NativeVideoCompressor.activateBackgroundTask({}, onExpired ? () => onExpired(undefined) : undefined); }, deactivateBackgroundTask() { - VideoCompressEventEmitter.removeAllListeners('backgroundTaskExpired'); return NativeVideoCompressor.deactivateBackgroundTask({}); }, } as VideoCompressorType; diff --git a/src/specs/Compressor.nitro.ts b/src/specs/Compressor.nitro.ts new file mode 100644 index 00000000..71936267 --- /dev/null +++ b/src/specs/Compressor.nitro.ts @@ -0,0 +1,56 @@ +import type { AnyMap, HybridObject } from 'react-native-nitro-modules'; + +export interface VideoThumbnailResult { + path: string; + size: number; + mime: string; + width: number; + height: number; +} + +/** + * Nitro HybridObject spec for the single native `Compressor` module. + * + * Notes on the migration from the old TurboModule spec: + * - Progress is delivered through callback parameters (`onProgress` / `onDownloadProgress` / + * `onExpired`) instead of `NativeEventEmitter` events. Callbacks can't live inside an `AnyMap`, + * so any progress callback that used to be nested in the options object is now a top-level param. + * - `optionMap`/`options` stay untyped (`AnyMap`) and are parsed natively, exactly as before. + * - `getImageMetaData` / `getVideoMetaData` / `upload` resolve objects at runtime (EXIF map / + * metadata map / `{status,headers,body}`), so they return `AnyMap` — the old `Promise` + * typing was lossy. + * - `uuid` is still threaded inside the options map for cancellation (`cancelCompression` / + * `cancelUpload`) and for routing native progress emissions to the registered callback. + */ +export interface Compressor extends HybridObject<{ ios: 'swift'; android: 'kotlin' }> { + // Image + image_compress(imagePath: string, optionMap: AnyMap, onDownloadProgress?: (progress: number) => void): Promise; + getImageMetaData(filePath: string): Promise; + + // Video + compress( + fileUrl: string, + optionMap: AnyMap, + onProgress?: (progress: number) => void, + onDownloadProgress?: (progress: number) => void, + ): Promise; + cancelCompression(uuid: string): void; + getVideoMetaData(filePath: string): Promise; + activateBackgroundTask(options: AnyMap, onExpired?: () => void): Promise; + deactivateBackgroundTask(options: AnyMap): Promise; + + // Audio + compress_audio(fileUrl: string, optionMap: AnyMap): Promise; + + // Upload / Download + upload(fileUrl: string, options: AnyMap, onProgress?: (written: number, total: number) => void): Promise; + cancelUpload(uuid: string, shouldCancelAll: boolean): void; + download(fileUrl: string, options: AnyMap, onProgress?: (progress: number) => void): Promise; + + // Others + generateFilePath(fileExtension: string): Promise; + getRealPath(path: string, type: string): Promise; + getFileSize(filePath: string): Promise; + createVideoThumbnail(fileUrl: string, options: AnyMap): Promise; + clearCache(cacheDir?: string): Promise; +} diff --git a/src/utils/Downloader.tsx b/src/utils/Downloader.tsx index 2f0814ac..dd1766d7 100644 --- a/src/utils/Downloader.tsx +++ b/src/utils/Downloader.tsx @@ -1,31 +1,12 @@ -import { NativeEventEmitter, Platform } from 'react-native'; -import type { NativeEventSubscription } from 'react-native'; +import { Platform } from 'react-native'; import { Compressor } from '../Main'; -const CompressEventEmitter = new NativeEventEmitter(Compressor); -import { uuidv4 } from './helpers'; +import { toNativeOptions, uuidv4 } from './helpers'; + export const download = async (fileUrl: string, downloadProgress?: (progress: number) => void, progressDivider?: number): Promise => { - let subscription: NativeEventSubscription; - try { - const uuid = uuidv4(); - if (downloadProgress) { - subscription = CompressEventEmitter.addListener('downloadProgress', (event: any) => { - if (event.uuid === uuid) { - downloadProgress(event.data.progress); - } - }); - } - if (Platform.OS === 'android' && fileUrl.includes('file://')) { - fileUrl = fileUrl.replace('file://', ''); - } - const result = await Compressor.download(fileUrl, { - uuid, - progressDivider, - }); - return result; - } finally { - // @ts-ignore - if (subscription) { - subscription.remove(); - } + const uuid = uuidv4(); + if (Platform.OS === 'android' && fileUrl.includes('file://')) { + fileUrl = fileUrl.replace('file://', ''); } + const result = await Compressor.download(fileUrl, toNativeOptions({ uuid, progressDivider }), downloadProgress); + return result; }; diff --git a/src/utils/Uploader.tsx b/src/utils/Uploader.tsx index 3c5025ae..64e7316c 100644 --- a/src/utils/Uploader.tsx +++ b/src/utils/Uploader.tsx @@ -1,8 +1,6 @@ -import { NativeEventEmitter, Platform } from 'react-native'; -import type { NativeEventSubscription } from 'react-native'; +import { Platform } from 'react-native'; import { Compressor } from '../Main'; -const CompressEventEmitter = new NativeEventEmitter(Compressor); -import { uuidv4 } from './helpers'; +import { toNativeOptions, uuidv4 } from './helpers'; export enum UploadType { BINARY_CONTENT = 0, MULTIPART = 1, @@ -51,15 +49,7 @@ export const backgroundUpload = async ( abortSignal?: AbortSignal, ): Promise => { const uuid = uuidv4(); - let subscription: NativeEventSubscription; try { - if (onProgress) { - subscription = CompressEventEmitter.addListener('uploadProgress', (event: any) => { - if (event.uuid === uuid) { - onProgress(event.data.written, event.data.total); - } - }); - } if (Platform.OS === 'android' && fileUrl.includes('file://')) { fileUrl = fileUrl.replace('file://', ''); } @@ -70,20 +60,18 @@ export const backgroundUpload = async ( abortSignal?.addEventListener('abort', () => cancelUpload(uuid)); - const result = await Compressor.upload(fileUrl, { - uuid, - method: options.httpMethod, - headers: options.headers, - uploadType: options.uploadType, - ...options, - url, - }); + const result = await Compressor.upload( + fileUrl, + toNativeOptions({ + ...options, + uuid, + method: options.httpMethod, + url, + }), + onProgress, + ); return result; } finally { - // @ts-ignore - if (subscription) { - subscription.remove(); - } abortSignal?.removeEventListener('abort', () => cancelUpload(uuid)); } }; diff --git a/src/utils/helpers.ts b/src/utils/helpers.ts index fb22aa74..de87c9a5 100644 --- a/src/utils/helpers.ts +++ b/src/utils/helpers.ts @@ -1,3 +1,5 @@ +import type { AnyMap } from 'react-native-nitro-modules'; + export const uuidv4 = () => { return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) { const r = (parseFloat('0.' + Math.random().toString().replace('0.', '') + new Date().getTime()) * 16) | 0, @@ -6,3 +8,19 @@ export const uuidv4 = () => { return v.toString(16); }); }; + +/** + * Build a Nitro-safe options map. Nitro's `AnyMap` converter throws when it + * encounters a `undefined` or function-valued property, so we drop both — + * progress callbacks are passed as separate native method parameters now. + */ +export const toNativeOptions = (options: Record): AnyMap => { + const result: Record = {}; + for (const key of Object.keys(options)) { + const value = options[key]; + if (value !== undefined && typeof value !== 'function') { + result[key] = value; + } + } + return result as AnyMap; +}; diff --git a/src/utils/index.tsx b/src/utils/index.tsx index 56482f72..ad10a2d2 100644 --- a/src/utils/index.tsx +++ b/src/utils/index.tsx @@ -1,5 +1,6 @@ import { Compressor } from '../Main'; import { Platform } from 'react-native'; +import { toNativeOptions } from './helpers'; type qualityType = 'low' | 'medium' | 'high'; const INCORRECT_INPUT_PATH = 'Incorrect input path. Please provide a valid one'; @@ -72,7 +73,7 @@ export const getRealPath: getRealPathType = (path, type = 'video') => { }; export const getVideoMetaData: getVideoMetaDataType = (path: string) => { - return Compressor.getVideoMetaData(path); + return Compressor.getVideoMetaData(path) as ReturnType; }; const unifyMetaData = (exifResult: any) => { @@ -96,7 +97,7 @@ export const getImageMetaData: getImageMetaDataType = async (path: string) => { }; export const createVideoThumbnail: createVideoThumbnailType = (fileUrl, options = {}) => { - return Compressor.createVideoThumbnail(fileUrl, options); + return Compressor.createVideoThumbnail(fileUrl, toNativeOptions(options)); }; export const clearCache: clearCacheType = (cacheDir?: string) => { diff --git a/yarn.lock b/yarn.lock index 47879266..97c3ca6b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5601,6 +5601,17 @@ __metadata: languageName: node linkType: hard +"@ts-morph/common@npm:~0.29.0": + version: 0.29.0 + resolution: "@ts-morph/common@npm:0.29.0" + dependencies: + minimatch: "npm:^10.0.1" + path-browserify: "npm:^1.0.1" + tinyglobby: "npm:^0.2.14" + checksum: 10c0/97ab8ca66558b817e5475a8893ee1476ab760a3ac71fa9868413d95ddbaaf909eda81716e3a8d14291cbf449e624af66de81bcddd3ec2e36525728b4edf5e8ee + languageName: node + linkType: hard + "@turbo/darwin-64@npm:2.9.6": version: 2.9.6 resolution: "@turbo/darwin-64@npm:2.9.6" @@ -7473,7 +7484,7 @@ __metadata: languageName: node linkType: hard -"chalk@npm:^5.6.2": +"chalk@npm:^5.3.0, chalk@npm:^5.6.2": version: 5.6.2 resolution: "chalk@npm:5.6.2" checksum: 10c0/99a4b0f0e7991796b1e7e3f52dceb9137cae2a9dfc8fc0784a550dc4c558e15ab32ed70b14b21b52beb2679b4892b41a0aa44249bcb996f01e125d58477c6976 @@ -7725,6 +7736,13 @@ __metadata: languageName: node linkType: hard +"code-block-writer@npm:^13.0.3": + version: 13.0.3 + resolution: "code-block-writer@npm:13.0.3" + checksum: 10c0/87db97b37583f71cfd7eced8bf3f0a0a0ca53af912751a734372b36c08cd27f3e8a4878ec05591c0cd9ae11bea8add1423e132d660edd86aab952656dd41fd66 + languageName: node + linkType: hard + "collect-v8-coverage@npm:^1.0.2": version: 1.0.3 resolution: "collect-v8-coverage@npm:1.0.3" @@ -14128,7 +14146,7 @@ __metadata: languageName: node linkType: hard -"minimatch@npm:^10.2.2": +"minimatch@npm:^10.0.1, minimatch@npm:^10.2.2": version: 10.2.5 resolution: "minimatch@npm:10.2.5" dependencies: @@ -14441,6 +14459,21 @@ __metadata: languageName: node linkType: hard +"nitrogen@npm:^0.35.9": + version: 0.35.9 + resolution: "nitrogen@npm:0.35.9" + dependencies: + chalk: "npm:^5.3.0" + react-native-nitro-modules: "npm:^0.35.9" + ts-morph: "npm:^28.0.0" + yargs: "npm:^18.0.0" + zod: "npm:^4.0.5" + bin: + nitrogen: lib/index.js + checksum: 10c0/1138573077003a27498902065318afb8928976df488c352105036977b46e9b99a4c3adfb246adb8a005fa793c8f8a4fbb1f2923211d16352cf7f9a5b30423fbe + languageName: node + linkType: hard + "nocache@npm:^3.0.1": version: 3.0.4 resolution: "nocache@npm:3.0.4" @@ -15125,6 +15158,13 @@ __metadata: languageName: node linkType: hard +"path-browserify@npm:^1.0.1": + version: 1.0.1 + resolution: "path-browserify@npm:1.0.1" + checksum: 10c0/8b8c3fd5c66bd340272180590ae4ff139769e9ab79522e2eb82e3d571a89b8117c04147f65ad066dccfb42fcad902e5b7d794b3d35e0fd840491a8ddbedf8c66 + languageName: node + linkType: hard + "path-exists@npm:^4.0.0": version: 4.0.0 resolution: "path-exists@npm:4.0.0" @@ -15752,6 +15792,7 @@ __metadata: react-native-get-random-values: "npm:^2.0.0" react-native-image-picker: "npm:^8.2.1" react-native-monorepo-config: "npm:^0.3.3" + react-native-nitro-modules: "npm:^0.35.9" react-native-progress: "npm:^5.0.1" react-native-reanimated: "npm:^4.3.0" react-native-safe-area-context: "npm:^5.7.0" @@ -15783,6 +15824,7 @@ __metadata: react-dom: "npm:19.1.0" react-native: "npm:0.81.5" react-native-get-random-values: "npm:^1.9.0" + react-native-nitro-modules: "npm:^0.35.9" react-native-progress: "npm:^5.0.0" react-native-reanimated: "npm:^4.3.0" react-native-safe-area-context: "npm:~5.6.0" @@ -15819,17 +15861,20 @@ __metadata: eslint-plugin-prettier: "npm:^5.5.5" jest: "npm:^30.3.0" lefthook: "npm:^2.1.4" + nitrogen: "npm:^0.35.9" prettier: "npm:^3.8.1" react: "npm:19.2.3" react-native: "npm:0.85.0" react-native-builder-bob: "npm:^0.41.0" react-native-harness: "npm:1.1.0" + react-native-nitro-modules: "npm:^0.35.9" release-it: "npm:^19.2.4" turbo: "npm:^2.8.21" typescript: "npm:^6.0.2" peerDependencies: react: "*" react-native: "*" + react-native-nitro-modules: ">=0.35.0" languageName: unknown linkType: soft @@ -15902,6 +15947,16 @@ __metadata: languageName: node linkType: hard +"react-native-nitro-modules@npm:^0.35.9": + version: 0.35.9 + resolution: "react-native-nitro-modules@npm:0.35.9" + peerDependencies: + react: "*" + react-native: "*" + checksum: 10c0/2e5c1b3eed1d187e7c2bbbf661e6405cb69c6a20b01427ea4c8a3bfe07a4baf2efbeb170a21f8c17fb0e18f022244f4b1fe1ce69a7ec824a7a4cf72441893cf1 + languageName: node + linkType: hard + "react-native-progress@npm:^5.0.0, react-native-progress@npm:^5.0.1": version: 5.0.1 resolution: "react-native-progress@npm:5.0.1" @@ -17854,6 +17909,16 @@ __metadata: languageName: node linkType: hard +"tinyglobby@npm:^0.2.14": + version: 0.2.17 + resolution: "tinyglobby@npm:0.2.17" + dependencies: + fdir: "npm:^6.5.0" + picomatch: "npm:^4.0.4" + checksum: 10c0/7f7bb0f197c88bc4b20c231e0deca4240ca3bf313a88f5a7fee93a872b84966a4d50220947c0455ad07a60b3b360961c5b7fd979222aeb716a9f99b412002e4c + languageName: node + linkType: hard + "tinyrainbow@npm:^3.0.3": version: 3.1.0 resolution: "tinyrainbow@npm:3.1.0" @@ -17928,6 +17993,16 @@ __metadata: languageName: node linkType: hard +"ts-morph@npm:^28.0.0": + version: 28.0.0 + resolution: "ts-morph@npm:28.0.0" + dependencies: + "@ts-morph/common": "npm:~0.29.0" + code-block-writer: "npm:^13.0.3" + checksum: 10c0/969570a5e983b4193735fff43878ce7b78787b4860bf3d23f330c86d7de707286969e5a82d486dce333eea27bf9d373a46f9de58a80c0bf86d2a462620563a55 + languageName: node + linkType: hard + "tsconfig-paths@npm:^3.15.0": version: 3.15.0 resolution: "tsconfig-paths@npm:3.15.0" @@ -19077,6 +19152,13 @@ __metadata: languageName: node linkType: hard +"zod@npm:^4.0.5": + version: 4.4.3 + resolution: "zod@npm:4.4.3" + checksum: 10c0/7ea31b558e88f9faf44f31dd185e2e1cbf51fed3081787fb96cc2534749b50c0acfc6da7f0922a7353ed092dd358c7d50c28ea96c94d04af64191bd33152eca3 + languageName: node + linkType: hard + "zustand@npm:^5.0.5": version: 5.0.12 resolution: "zustand@npm:5.0.12" From 055a4ace0fb07740bd539d6e0c76e871a9125ca1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=E1=BB=AFu=20Nguy=E1=BB=85n?= Date: Tue, 9 Jun 2026 11:09:36 +0700 Subject: [PATCH 09/13] chore: update CLAUDE.md --- CLAUDE.md | 37 +++++++++++++++++++++++++++---------- 1 file changed, 27 insertions(+), 10 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 358f57e6..e6a41dfd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,14 +6,16 @@ ## Tech Stack & Architecture -**JS layer:** TypeScript · single native module `Compressor` resolved in `src/Main.tsx` (TurboModule on New Arch, `NativeModules` fallback on old arch) +**JS layer:** TypeScript · single native module `Compressor` exposed as a **Nitro HybridObject** (`react-native-nitro-modules`), resolved in `src/Main.tsx` via `NitroModules.createHybridObject` **Android native:** Kotlin · hand-rolled MediaCodec/MediaMuxer video transcoder -**iOS native:** Swift · AVFoundation via vendored `NextLevelSessionExporter.swift` +**iOS native:** Swift (C++/Swift interop) · AVFoundation via vendored `NextLevelSessionExporter.swift` +**Codegen:** Nitrogen (`yarn nitrogen`) generates the native bindings from the `*.nitro.ts` spec into `nitrogen/generated/` (committed to git) **Tooling:** Yarn 4 (Berry) workspace (`examples/*`) · Node `>= 22.11` · Jest (native mocked) · react-native-builder-bob · Expo config plugin +**Requirements (Nitro):** RN ≥ 0.75 · iOS ≥ 13.4 / Xcode ≥ 16.4 · Android compileSdk ≥ 34 · C++20. Works on both old & new architecture (Nitro handles its own linking) ### JS wrapper → single native module -All native functionality is exposed through one module called `Compressor`. `src/Main.tsx` resolves it once: it uses the TurboModule spec (`src/Spec/NativeCompressor.ts`) when `global.__turboModuleProxy` exists (New Architecture), otherwise falls back to `NativeModules.Compressor` (old arch), and wraps a `Proxy` that throws a linking error if the module is missing. +All native functionality is exposed through one Nitro HybridObject named `Compressor`. `src/Main.tsx` resolves it once via `NitroModules.createHybridObject('Compressor')`, typed by the spec `src/specs/Compressor.nitro.ts`, and re-throws a friendly linking error if Nitro can't find it. Options are passed as Nitro `AnyMap` (untyped maps), parsed natively as before. The public API is assembled in `src/index.tsx` from four domain modules plus utils: @@ -22,14 +24,24 @@ The public API is assembled in `src/index.tsx` from four domain modules plus uti - `src/Audio/index.tsx` — `Audio.compress` - `src/utils/` — `Uploader.tsx` (`backgroundUpload`, `cancelUpload`), `Downloader.tsx` (`download`), `helpers.ts`, and metadata/path helpers (`getRealPath`, `getVideoMetaData`, `getImageMetaData`, `generateFilePath`, `createVideoThumbnail`, `clearCache`, `getFileSize`) -### Progress is delivered via events, not the Promise +### Progress is delivered via callbacks, not events -Each `compress`/`upload`/`download` call generates a `uuid` (`uuidv4`) in JS and passes it to native. Native emits events on a `NativeEventEmitter` (`videoCompressProgress`, `downloadProgress`, `uploadProgress`, `backgroundTaskExpired`); the JS wrapper subscribes, **filters events by matching `event.uuid`**, forwards `event.data.progress` to the user callback, and removes the subscription in a `finally` block when the Promise settles. Cancellation (`cancelCompression`, `cancelUpload`) and `AbortController` signals also key off this uuid. When editing progress/cancellation logic, keep the uuid threading consistent across JS and both native sides. +Nitro has no `NativeEventEmitter`. Progress is delivered through **callback functions passed as method parameters** (`onProgress`, `onDownloadProgress`, `onExpired`) — first-class, reference-counted, auto-scheduled onto the JS thread. Callbacks can't live inside an `AnyMap`, so any callback that used to be nested in the options object is lifted to a top-level method parameter (the JS layer strips functions/`undefined` from option maps via `toNativeOptions` in `src/utils/helpers.ts`, since Nitro's AnyMap throws on those). + +A `uuid` (`uuidv4`) is still generated in JS and threaded inside the options map, but now only for (a) cancellation (`cancelCompression`, `cancelUpload`, `AbortController`) and (b) routing native progress emissions to the correct callback. Natively, the per-domain code still calls `EventEmitterHandler.emit*`, but that class is now a **uuid → callback registry** (not a bridge emitter): the binding registers the JS callback under the uuid before invoking the domain method and unregisters when the Promise settles. Keep the uuid threading consistent across JS and both native sides. ### Native code organization (mirrors the JS domains) +The thin Nitro binding lives separately from the heavy domain logic: + +- **Android binding** `android/src/main/java/com/margelo/nitro/compressor/HybridCompressor.kt` (extends the generated `HybridCompressorSpec`) converts `AnyMap` → `ReadableMap`, bridges the Nitro `Promise` to the domain layer's `com.facebook.react.bridge.Promise` via `NitroPromiseAdapter.kt`, and runs domain work on a background executor. `NitroCompressorPackage.kt` (a `BaseReactPackage`) exists only so RN autolinking registers the Gradle project and its `companion init` loads `libNitroCompressor.so`. +- **iOS binding** `ios/HybridCompressor.swift` (implements `HybridCompressorSpec`) converts `AnyMap` → `NSDictionary`, synthesizes `RCTPromiseResolveBlock`/`RejectBlock` to drive the Nitro `Promise`. + +Heavy domain logic (unchanged, mirrors the JS domains): + - **Android** `android/src/main/java/com/reactnativecompressor/` → `Image/`, `Video/`, `Audio/`, `Utils/`. The video transcoder is hand-rolled under `Video/VideoCompressor/` (MediaCodec/MediaMuxer pipeline: `Compressor.kt`, `MP4Builder.kt`, surfaces/renderer, `utils/`). `VideoMain.compress` routes to auto vs manual via `VideoCompressorHelper`. `StreamableVideo.kt` moves the `moov` atom to the front of the output by default — preserve this behavior. -- **iOS** `ios/` → `Image/`, `Audio/` (with `FormatConverter/`), `Video/`, `Utils/`. Video uses `NextLevelSessionExporter.swift` (a vendored AVFoundation exporter) driven by `VideoMain.swift`. Event emission goes through `EventEmitterHandler.swift`. +- **iOS** `ios/` → `Image/`, `Audio/` (with `FormatConverter/`), `Video/`, `Utils/`. Video uses `NextLevelSessionExporter.swift` (a vendored AVFoundation exporter) driven by `VideoMain.swift`. Domain Swift files `import React` for `RCTPromise*` (bridging headers are unsupported under RN 0.85 framework linkage), and `FormatConverter`/`AudioFileFormat` are `internal` to keep them out of the Swift↔C++ interop surface. +- On both platforms `EventEmitterHandler` is the uuid→callback registry described above. ### Expo support @@ -50,8 +62,9 @@ yarn typecheck # tsc --noEmit yarn lint # eslint over **/*.{js,ts,tsx} yarn lint --fix # auto-fix lint/prettier yarn test:pr # full PR gate: test --runInBand + typecheck + lint -yarn prepack # build the publishable lib/ via react-native-builder-bob -yarn clean # delete android/ios build dirs (run before switching archs) +yarn nitrogen # regenerate Nitro native bindings into nitrogen/generated/ (run after editing the *.nitro.ts spec) +yarn prepack # nitrogen + build the publishable lib/ via react-native-builder-bob +yarn clean # delete android/ios build dirs ``` Example apps (workspace shortcuts): @@ -77,9 +90,13 @@ Device/version overrides live in `examples/bare/rn-harness.config.mjs` (env vars --- ## Gotchas -- **Spec in FOUR places — keep in sync.** Adding/renaming/changing a native method must touch all of: (1) `src/Spec/NativeCompressor.ts` (codegen TurboModule `Spec`, New Arch source of truth, library `RNCompressorSpec`); (2) `android/src/oldarch/CompressorSpec.kt` (abstract, old arch) + `android/src/newarch/CompressorSpec.kt` (extends codegen `NativeCompressorSpec`) — selected at build by `newArchEnabled` in `android/build.gradle`; (3) `android/src/main/java/com/reactnativecompressor/CompressorModule.kt` (impl, delegates to per-domain `*Main`; registered via `CompressorPackage.kt`/`TurboReactPackage`); (4) iOS `ios/Compressor.mm` (`RCT_EXTERN_METHOD` + TurboModule binding under `RCT_NEW_ARCH_ENABLED`) + `ios/CompressorManager.swift` (`@objc(Compressor) RCTEventEmitter`). +- **One Nitro spec — regenerate after editing.** The spec lives in ONE place: `src/specs/Compressor.nitro.ts` (config in `nitro.json`). After adding/renaming/changing a method, run `yarn nitrogen` and commit the updated `nitrogen/generated/`, then update the two implementations: `ios/HybridCompressor.swift` and `android/.../com/margelo/nitro/compressor/HybridCompressor.kt`. (No more old/new-arch specs, `Compressor.mm`, or `RCTEventEmitter` — those were deleted.) +- **Nitrogen Swift keyword gotcha:** don't name a spec parameter after a Swift keyword (e.g. `extension`) — nitrogen emits it unescaped and the generated Swift won't compile. `generateFilePath` uses `fileExtension` for this reason. +- **iOS framework linkage:** RN 0.85 builds pods as frameworks, where bridging headers are unsupported. Swift files needing React types must `import React` (and `import UIKit` for UIKit types). Public Swift value types with nested types (e.g. `FormatConverter`) must stay `internal` or they break the Swift↔C++ interop link. +- **Android autolink/.so:** `NitroCompressorPackage` must exist (RN CLI keys autolinking off a `ReactPackage`, and its `init` loads `libNitroCompressor.so`). After changing it, clear `examples/bare/android/build/generated/autolinking` if the project stops being found. +- **AnyMap is strict:** option maps must contain only JSON-like values (no functions, no `undefined`) — use `toNativeOptions`. Numbers arrive natively as `Double`; the binding round-trips through `ReadableMap`/`NSNumber` so the domain parsers' `getInt`/`as? Int` keep working. - **Streamable:** `StreamableVideo.kt` moves `moov` atom to front by default — preserve. -- **uuid threading:** keep `uuid` consistent across JS + both native sides for progress/cancellation. +- **uuid threading:** keep `uuid` consistent across JS + both native sides for cancellation + progress-callback routing. - **Commits follow Conventional Commits** (`fix:`, `feat:`, `refactor:`, `docs:`, `test:`, `chore:`). `commit-msg` hook runs commitlint; `pre-commit` hook (lefthook) runs eslint + `tsc --noEmit` on staged files. Don't bypass. - **Build output:** `lib/` and example workspaces excluded from lint/tsc/jest — don't edit `lib/` by hand. - **Releases:** cut with `yarn release` (release-it + conventional-changelog). From 300c16ac4dbd912acad409548a92cf887589c142 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=E1=BB=AFu=20Nguy=E1=BB=85n?= Date: Tue, 9 Jun 2026 21:37:57 +0700 Subject: [PATCH 10/13] fix(ios): drop `import React` so Nitro Swift/C++ interop compiles Under Nitro the pod compiles with Swift<->C++ interop. `import React` dragged React's move-only C++ type jsinspector RuntimeSamplingProfile into Swift's ClangImporter, which Xcode 16.4 mis-imports as copyable, failing the build (`__construct_at` / SwiftCompile error, exit 65). React was only used for the RCTPromiseResolveBlock/RejectBlock typedefs (now synthesized and consumed entirely in Swift) and one vestigial RCTResizeMode.contain. Define the two blocks as local Swift typealiases and replace the enum with a local stand-in, so no Swift file imports React. Co-Authored-By: Claude Opus 4.8 (1M context) --- ios/Audio/AudioMain.swift | 1 - ios/HybridCompressor.swift | 9 ++++++++- ios/Image/ImageCompressor.swift | 10 ++++++++-- ios/Image/ImageMain.swift | 1 - ios/Utils/CreateVideoThumbnail.swift | 1 - ios/Utils/Uploader.swift | 1 - ios/Utils/Utils.swift | 1 - ios/Video/VideoMain.swift | 1 - 8 files changed, 16 insertions(+), 9 deletions(-) diff --git a/ios/Audio/AudioMain.swift b/ios/Audio/AudioMain.swift index 766b7369..b3ac4973 100644 --- a/ios/Audio/AudioMain.swift +++ b/ios/Audio/AudioMain.swift @@ -7,7 +7,6 @@ import AVFoundation -import React class AudioMain{ static func compress_audio(_ fileUrl: String, optionMap: NSDictionary, resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) { do { diff --git a/ios/HybridCompressor.swift b/ios/HybridCompressor.swift index 70dab99f..d76ed7f2 100644 --- a/ios/HybridCompressor.swift +++ b/ios/HybridCompressor.swift @@ -12,7 +12,14 @@ import Foundation import NitroModules -import React + +// Previously imported from React. We define them locally so this Swift module no +// longer needs to `import React`: under Nitro's Swift↔C++ interop, importing +// React pulls move-only C++ types (e.g. jsinspector's RuntimeSamplingProfile) +// into Swift's importer and fails to compile. These blocks are created here and +// consumed by the domain layer entirely in Swift, so plain closures suffice. +typealias RCTPromiseResolveBlock = (Any?) -> Void +typealias RCTPromiseRejectBlock = (String?, String?, (any Error)?) -> Void private let videoCompressor = VideoCompressor() private let uploader = Uploader() diff --git a/ios/Image/ImageCompressor.swift b/ios/Image/ImageCompressor.swift index b7cb642a..2bcc790f 100644 --- a/ios/Image/ImageCompressor.swift +++ b/ios/Image/ImageCompressor.swift @@ -1,11 +1,17 @@ import Accelerate import CoreGraphics import Photos -import React import Foundation import MobileCoreServices +// Local stand-in for the single `RCTResizeMode` case this file relies on, so the +// module no longer needs to `import React` (see HybridCompressor for the reason). +private enum CompressorResizeMode { + case contain +} + + class ImageCompressor { static func findTargetSize(_ image: UIImage, maxWidth: Int, maxHeight: Int) -> CGSize { let width = image.size.width @@ -392,7 +398,7 @@ class ImageCompressor { let imageURL = URL(string: imagePath.replacingOccurrences(of: " ", with: "%20")) var size = CGSize.zero var scale: CGFloat = 1 - var resizeMode = RCTResizeMode.contain + var resizeMode = CompressorResizeMode.contain let assetID = imagePath.replacingOccurrences(of: "ph://", with: "") let results = PHAsset.fetchAssets(withLocalIdentifiers: [assetID], options: nil) diff --git a/ios/Image/ImageMain.swift b/ios/Image/ImageMain.swift index 14008134..2e6e9fc0 100644 --- a/ios/Image/ImageMain.swift +++ b/ios/Image/ImageMain.swift @@ -6,7 +6,6 @@ // import Foundation -import React class ImageMain { static func image_compress(_ value: String, optionMap: NSDictionary, resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) { do { diff --git a/ios/Utils/CreateVideoThumbnail.swift b/ios/Utils/CreateVideoThumbnail.swift index 7bfa7c1f..9282b923 100644 --- a/ios/Utils/CreateVideoThumbnail.swift +++ b/ios/Utils/CreateVideoThumbnail.swift @@ -8,7 +8,6 @@ import Foundation import AVFoundation import UIKit -import React class CreateVideoThumbnail: NSObject { private static let defaultQuality = 0.9 diff --git a/ios/Utils/Uploader.swift b/ios/Utils/Uploader.swift index a62e83af..2e6cd0aa 100644 --- a/ios/Utils/Uploader.swift +++ b/ios/Utils/Uploader.swift @@ -7,7 +7,6 @@ import Foundation import MobileCoreServices -import React enum UploaderUploadType: Int { case UploaderInvalidType = -1 diff --git a/ios/Utils/Utils.swift b/ios/Utils/Utils.swift index 61f9e1ea..a08fa920 100644 --- a/ios/Utils/Utils.swift +++ b/ios/Utils/Utils.swift @@ -6,7 +6,6 @@ // import Foundation -import React class Utils { static func generateCacheFilePath(_ extension: String) -> String { diff --git a/ios/Video/VideoMain.swift b/ios/Video/VideoMain.swift index 8759d067..22a52ece 100644 --- a/ios/Video/VideoMain.swift +++ b/ios/Video/VideoMain.swift @@ -3,7 +3,6 @@ import AVFoundation import Photos import MobileCoreServices import UIKit -import React struct CompressionError: Error { private let message: String From a95d6ad0f4c1771dd03cc58b6cecffb51ebaf5cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=E1=BB=AFu=20Nguy=E1=BB=85n?= Date: Tue, 9 Jun 2026 21:38:20 +0700 Subject: [PATCH 11/13] fix(android): disable WrongThread lint on library subprojects The Validate Android job runs `./gradlew lint`, which lints every autolinked dependency. react-native-video trips the WrongThread check (ReactExoplayerView.getVideoTrackInfoFromManifest) -- not our code -- and aborts the lint build (pre-existing failure, red on main too). Register a settings-time hook that disables just that one check on android library subprojects, before AGP locks the lint DSL. Co-Authored-By: Claude Opus 4.8 (1M context) --- examples/bare/android/settings.gradle | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/examples/bare/android/settings.gradle b/examples/bare/android/settings.gradle index e2448249..d004245c 100644 --- a/examples/bare/android/settings.gradle +++ b/examples/bare/android/settings.gradle @@ -4,3 +4,15 @@ extensions.configure(com.facebook.react.ReactSettingsExtension){ ex -> ex.autoli rootProject.name = 'BareExample' include ':app' includeBuild('../node_modules/@react-native/gradle-plugin') + +// The "Validate Android" CI job runs `./gradlew lint`, which lints every +// autolinked subproject — including third-party deps. react-native-video trips +// the `WrongThread` check (ReactExoplayerView.getVideoTrackInfoFromManifest), +// which is not our code and aborts the lint build. Disable just that one check +// on Android library subprojects. Registered here (before any project is +// evaluated) so AGP hasn't locked the lint DSL by the time we add to it. +gradle.beforeProject { project -> + project.plugins.withId("com.android.library") { + project.android.lint.disable.add("WrongThread") + } +} From f9610d2e45e55d9a933a2b7d5c51b53af080ae75 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=E1=BB=AFu=20Nguy=E1=BB=85n?= Date: Tue, 9 Jun 2026 22:02:54 +0700 Subject: [PATCH 12/13] fix(android): silence dependency manifest lint errors in example app After disabling WrongThread on library subprojects, the example app's own lint still failed on two manifest-merge errors from third-party deps: react-native-video's VideoPlaybackService (NotificationPermission) and the media/camera permissions (PermissionImpliesUnsupportedChromeOsHardware). Disable just those two checks in the app's lint config. Full `./gradlew lint` now passes locally. Co-Authored-By: Claude Opus 4.8 (1M context) --- examples/bare/android/app/build.gradle | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/examples/bare/android/app/build.gradle b/examples/bare/android/app/build.gradle index e134059e..f84330d1 100644 --- a/examples/bare/android/app/build.gradle +++ b/examples/bare/android/app/build.gradle @@ -105,6 +105,15 @@ android { proguardFiles getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro" } } + + lint { + // The example app merges manifests from third-party deps (react-native-video's + // VideoPlaybackService, media/camera permissions from the image & video pickers), + // which trip these checks. They are not our library's code; silence just these so + // the "Validate Android" lint job isn't blocked by dependency manifests. + disable.add("NotificationPermission") + disable.add("PermissionImpliesUnsupportedChromeOsHardware") + } } dependencies { From 7ff780f929984e99c768cfa97c8b7346c04d4f46 Mon Sep 17 00:00:00 2001 From: Numan Date: Fri, 12 Jun 2026 05:13:25 +0500 Subject: [PATCH 13/13] fix: example --- examples/bare/.ruby-version | 1 + examples/bare/ios/Podfile.lock | 6 +++--- examples/bare/src/Screens/Video/index.tsx | 2 +- examples/bare/src/Utils/index.tsx | 13 ++++++++++++- 4 files changed, 17 insertions(+), 5 deletions(-) create mode 100644 examples/bare/.ruby-version diff --git a/examples/bare/.ruby-version b/examples/bare/.ruby-version new file mode 100644 index 00000000..0aec50e6 --- /dev/null +++ b/examples/bare/.ruby-version @@ -0,0 +1 @@ +3.1.4 diff --git a/examples/bare/ios/Podfile.lock b/examples/bare/ios/Podfile.lock index 23cdb7fe..d3fe82ea 100644 --- a/examples/bare/ios/Podfile.lock +++ b/examples/bare/ios/Podfile.lock @@ -2570,7 +2570,7 @@ EXTERNAL SOURCES: SPEC CHECKSUMS: FBLazyVector: 26fd21c75314e101f280d401e97f27d54f3f7064 - hermes-engine: 0b984fdb4f1be8bb5220ab64fb47ccd0f65ee20d + hermes-engine: eaa65d42895b52c6d680c0aebfccfa50baccce3d NitroModules: 16bc17a076b12304d608f7c915b9d321f56dfc19 RCTDeprecation: c7a2768f905d76ca6d2cfefb26e4349eacbdfca3 RCTRequired: 5e502c3553cfbed090a991c444448da452fb752e @@ -2580,7 +2580,7 @@ SPEC CHECKSUMS: React: 13cf8451582adb1bb324306e1893b91d1cba28c6 React-callinvoker: 91e6a605826b684ad2e623811253b4d0c4196bef React-Core: 46818de5f211b2a2759ac823b591af8a0a95c2c1 - React-Core-prebuilt: 4c2f625ecba9bf71d16289246364b3b05af8fff7 + React-Core-prebuilt: 7393118ba8d9419fbaf6e4083c2ede4837d5d29c React-CoreModules: a6a37afee48d4a31ab398640b0795462647d5c67 React-cxxreact: 2ec3e2f7a8ae9303460d4ba94cde183ea90d64cd React-debug: 0d21117b897ce0359c9d2c9dfe952f237476a14a @@ -2648,7 +2648,7 @@ SPEC CHECKSUMS: ReactAppDependencyProvider: 22e2265d86a4e871e5e858f4e7ef1c8d01103680 ReactCodegen: 5bd23df5c8ad6c87df0bc8ccd391bd37bf6c92d5 ReactCommon: a804bb8d1dcf3ecdec3a77eb8bba19b7863bbbdb - ReactNativeDependencies: 8f281fcd80f3731161fae0a4c851c09bb2a5e79a + ReactNativeDependencies: a17cceda80e02709ea6124c4abae3917dc3aa06b ReactNativeFs: 21026144ac71a65acf6855c52b1a8eb31012bb5d RNCMaskedView: eb2b2e538afa907f05a5848a1a1ac26092e6fec9 RNReanimated: c4e6659e58b793885ae6da476cb514fc913e7b85 diff --git a/examples/bare/src/Screens/Video/index.tsx b/examples/bare/src/Screens/Video/index.tsx index 10fab2e9..2c5f75c2 100644 --- a/examples/bare/src/Screens/Video/index.tsx +++ b/examples/bare/src/Screens/Video/index.tsx @@ -48,7 +48,7 @@ export default function App() { (async () => { const detail: any = await getFileInfo(sourceVideo); setSourceSize(prettyBytes(parseInt(detail.size, 10))); - })(); + })().catch((error) => console.log({ sourceSizeError: error })); }, [sourceVideo]); useEffect(() => { diff --git a/examples/bare/src/Utils/index.tsx b/examples/bare/src/Utils/index.tsx index cc224d86..2702b25f 100644 --- a/examples/bare/src/Utils/index.tsx +++ b/examples/bare/src/Utils/index.tsx @@ -12,4 +12,15 @@ export const getFullFilename = (path: string | null) => { return ''; }; -export const getFileInfo = stat; +// RNFS `stat` expects a plain filesystem path. Picker URIs arrive as +// percent-encoded `file://` URLs (e.g. spaces become %20), which stat can't +// resolve. Strip the scheme and decode before delegating. +export const getFileInfo = (path: string) => { + let _path = path.startsWith('file://') ? path.replace('file://', '') : path; + try { + _path = decodeURIComponent(_path); + } catch { + // leave path as-is if it isn't valid percent-encoding + } + return stat(_path); +};