diff --git a/app.config.js b/app.config.js index bf10a1b7d8..b9b4a42a9d 100644 --- a/app.config.js +++ b/app.config.js @@ -285,7 +285,6 @@ module.exports = function (_config) { sounds: PLATFORM === 'ios' ? ['assets/dm.aiff'] : ['assets/dm.mp3'], }, ], - 'react-native-compressor', [ '@bitdrift/react-native', { diff --git a/modules/expo-bluesky-video-compress/android/build.gradle b/modules/expo-bluesky-video-compress/android/build.gradle deleted file mode 100644 index b1b719263b..0000000000 --- a/modules/expo-bluesky-video-compress/android/build.gradle +++ /dev/null @@ -1,15 +0,0 @@ -plugins { - id 'com.android.library' - id 'expo-module-gradle-plugin' -} - -group = 'expo.modules.blueskyvideocompress' -version = '1.0.0' - -android { - namespace "expo.modules.blueskyvideocompress" - defaultConfig { - versionCode 1 - versionName "1.0.0" - } -} diff --git a/modules/expo-bluesky-video-compress/android/src/main/java/expo/modules/blueskyvideocompress/CodecSelector.kt b/modules/expo-bluesky-video-compress/android/src/main/java/expo/modules/blueskyvideocompress/CodecSelector.kt deleted file mode 100644 index f35698486a..0000000000 --- a/modules/expo-bluesky-video-compress/android/src/main/java/expo/modules/blueskyvideocompress/CodecSelector.kt +++ /dev/null @@ -1,55 +0,0 @@ -package expo.modules.blueskyvideocompress - -import android.media.MediaCodecInfo -import android.media.MediaCodecList -import android.media.MediaFormat -import android.os.Build - -object CodecSelector { - // Source: https://github.com/numandev1/react-native-compressor/blob/f949b0868055178e7c8753e05202f784b1bcd589/android/src/main/java/com/reactnativecompressor/Video/VideoCompressor/compressor/Compressor.kt#L500 - private val AVC_DENYLIST = setOf( - "c2.qti.avc.encoder" - ) - - private val SOFTWARE_PREFIXES = listOf( - "OMX.google.", - "c2.android.", - "c2.google." - ) - - data class EncoderInfo( - val name: String, - val mime: String, - val isHardware: Boolean - ) - - fun findEncoder(mime: String, requireHardware: Boolean): EncoderInfo? { - val codecList = MediaCodecList(MediaCodecList.REGULAR_CODECS) - val candidates = codecList.codecInfos - .filter { it.isEncoder } - .filter { it.supportedTypes.any { t -> t.equals(mime, ignoreCase = true) } } - .filter { !it.name.contains("secure", ignoreCase = true) } - .filter { !(mime == MediaFormat.MIMETYPE_VIDEO_AVC && AVC_DENYLIST.contains(it.name)) } - - val hardware = candidates.filter { isHardware(it) } - val selected = if (requireHardware) { - hardware.firstOrNull() - } else { - hardware.firstOrNull() ?: candidates.firstOrNull() - } - selected ?: return null - return EncoderInfo( - name = selected.name, - mime = mime, - isHardware = isHardware(selected) - ) - } - - private fun isHardware(info: MediaCodecInfo): Boolean { - return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - info.isHardwareAccelerated - } else { - SOFTWARE_PREFIXES.none { info.name.startsWith(it, ignoreCase = true) } - } - } -} diff --git a/modules/expo-bluesky-video-compress/android/src/main/java/expo/modules/blueskyvideocompress/ExpoBlueskyVideoCompressModule.kt b/modules/expo-bluesky-video-compress/android/src/main/java/expo/modules/blueskyvideocompress/ExpoBlueskyVideoCompressModule.kt deleted file mode 100644 index 30b1def7c6..0000000000 --- a/modules/expo-bluesky-video-compress/android/src/main/java/expo/modules/blueskyvideocompress/ExpoBlueskyVideoCompressModule.kt +++ /dev/null @@ -1,62 +0,0 @@ -package expo.modules.blueskyvideocompress - -import expo.modules.kotlin.modules.Module -import expo.modules.kotlin.modules.ModuleDefinition -import java.util.concurrent.ConcurrentHashMap - -class ExpoBlueskyVideoCompressModule : Module() { - private val activeCompressors = ConcurrentHashMap() - - override fun definition() = ModuleDefinition { - Name("ExpoBlueskyVideoCompress") - - Events("onProgress") - - AsyncFunction("probe") { uri: String -> - val context = appContext.reactContext - ?: throw Error("React context is null") - return@AsyncFunction VideoProber.probe(context, uri) - } - - AsyncFunction("compress") { uri: String, options: Map -> - val context = appContext.reactContext - ?: throw Error("React context is null") - val targetBitrate = (options["targetBitrate"] as? Number)?.toInt() ?: 0 - val maxSize = (options["maxSize"] as? Number)?.toInt() ?: 1920 - val codecPref = (options["codec"] as? String) ?: "auto" - val frameRateCap = ((options["frameRateCap"] as? Number)?.toInt() ?: 30).coerceAtLeast(1) - val jobId = (options["jobId"] as? Number)?.toInt() ?: 0 - - val compressor = VideoCompressor( - context = context, - uriString = uri, - targetBitrate = targetBitrate, - maxSize = maxSize, - codecPref = codecPref, - frameRateCap = frameRateCap, - jobId = jobId, - onProgress = { id, progress -> - sendEvent("onProgress", mapOf( - "id" to id, - "progress" to progress - )) - } - ) - - activeCompressors[jobId] = compressor - - try { - val result = compressor.compress() - activeCompressors.remove(jobId) - return@AsyncFunction result - } catch (e: Exception) { - activeCompressors.remove(jobId) - throw e - } - } - - Function("cancel") { jobId: Int -> - activeCompressors.remove(jobId)?.cancel() - } - } -} diff --git a/modules/expo-bluesky-video-compress/android/src/main/java/expo/modules/blueskyvideocompress/InputSurface.kt b/modules/expo-bluesky-video-compress/android/src/main/java/expo/modules/blueskyvideocompress/InputSurface.kt deleted file mode 100644 index e2eee4768c..0000000000 --- a/modules/expo-bluesky-video-compress/android/src/main/java/expo/modules/blueskyvideocompress/InputSurface.kt +++ /dev/null @@ -1,96 +0,0 @@ -package expo.modules.blueskyvideocompress - -import android.opengl.EGL14 -import android.opengl.EGLConfig -import android.opengl.EGLContext -import android.opengl.EGLDisplay -import android.opengl.EGLExt -import android.opengl.EGLSurface -import android.view.Surface - -class InputSurface(private val surface: Surface) { - private var eglDisplay: EGLDisplay = EGL14.EGL_NO_DISPLAY - private var eglContext: EGLContext = EGL14.EGL_NO_CONTEXT - private var eglSurface: EGLSurface = EGL14.EGL_NO_SURFACE - - init { - eglSetup() - } - - private fun eglSetup() { - eglDisplay = EGL14.eglGetDisplay(EGL14.EGL_DEFAULT_DISPLAY) - if (eglDisplay === EGL14.EGL_NO_DISPLAY) { - throw RuntimeException("unable to get EGL14 display") - } - - val version = IntArray(2) - if (!EGL14.eglInitialize(eglDisplay, version, 0, version, 1)) { - throw RuntimeException("unable to initialize EGL14") - } - - val attribList = intArrayOf( - EGL14.EGL_RED_SIZE, 8, - EGL14.EGL_GREEN_SIZE, 8, - EGL14.EGL_BLUE_SIZE, 8, - EGL14.EGL_ALPHA_SIZE, 8, - EGL14.EGL_RENDERABLE_TYPE, EGL14.EGL_OPENGL_ES2_BIT, - EGL14.EGL_SURFACE_TYPE, EGL14.EGL_WINDOW_BIT, - EGL14.EGL_NONE - ) - val configs = arrayOfNulls(1) - val numConfigs = IntArray(1) - EGL14.eglChooseConfig(eglDisplay, attribList, 0, configs, 0, 1, numConfigs, 0) - checkEglError("eglChooseConfig") - - val contextAttribs = intArrayOf( - EGL14.EGL_CONTEXT_CLIENT_VERSION, 2, - EGL14.EGL_NONE - ) - eglContext = EGL14.eglCreateContext( - eglDisplay, configs[0], EGL14.EGL_NO_CONTEXT, contextAttribs, 0 - ) - checkEglError("eglCreateContext") - - val surfaceAttribs = intArrayOf(EGL14.EGL_NONE) - eglSurface = EGL14.eglCreateWindowSurface( - eglDisplay, configs[0], surface, surfaceAttribs, 0 - ) - checkEglError("eglCreateWindowSurface") - } - - fun makeCurrent() { - EGL14.eglMakeCurrent(eglDisplay, eglSurface, eglSurface, eglContext) - checkEglError("eglMakeCurrent") - } - - fun swapBuffers(): Boolean { - return EGL14.eglSwapBuffers(eglDisplay, eglSurface) - } - - fun setPresentationTime(nsecs: Long) { - EGLExt.eglPresentationTimeANDROID(eglDisplay, eglSurface, nsecs) - } - - fun release() { - if (eglDisplay !== EGL14.EGL_NO_DISPLAY) { - EGL14.eglMakeCurrent( - eglDisplay, EGL14.EGL_NO_SURFACE, EGL14.EGL_NO_SURFACE, EGL14.EGL_NO_CONTEXT - ) - EGL14.eglDestroySurface(eglDisplay, eglSurface) - EGL14.eglDestroyContext(eglDisplay, eglContext) - EGL14.eglReleaseThread() - EGL14.eglTerminate(eglDisplay) - } - surface.release() - eglDisplay = EGL14.EGL_NO_DISPLAY - eglContext = EGL14.EGL_NO_CONTEXT - eglSurface = EGL14.EGL_NO_SURFACE - } - - private fun checkEglError(msg: String) { - val error = EGL14.eglGetError() - if (error != EGL14.EGL_SUCCESS) { - throw RuntimeException("$msg: EGL error: 0x${Integer.toHexString(error)}") - } - } -} diff --git a/modules/expo-bluesky-video-compress/android/src/main/java/expo/modules/blueskyvideocompress/OutputSurface.kt b/modules/expo-bluesky-video-compress/android/src/main/java/expo/modules/blueskyvideocompress/OutputSurface.kt deleted file mode 100644 index fdf8c6eca2..0000000000 --- a/modules/expo-bluesky-video-compress/android/src/main/java/expo/modules/blueskyvideocompress/OutputSurface.kt +++ /dev/null @@ -1,61 +0,0 @@ -package expo.modules.blueskyvideocompress - -import android.graphics.SurfaceTexture -import android.os.Handler -import android.os.HandlerThread -import android.view.Surface - -class OutputSurface : SurfaceTexture.OnFrameAvailableListener { - private val renderer = TextureRenderer() - private var surfaceTexture: SurfaceTexture? = null - private val stMatrix = FloatArray(16) - private val callbackThread = HandlerThread("OutputSurfaceCallbacks") - val surface: Surface - - @Volatile - private var frameAvailable = false - private val frameSyncObject = Object() - - init { - renderer.surfaceCreated() - callbackThread.start() - val handler = Handler(callbackThread.looper) - surfaceTexture = SurfaceTexture(renderer.getTextureId()).also { - it.setOnFrameAvailableListener(this, handler) - } - surface = Surface(surfaceTexture) - } - - fun release() { - surface.release() - surfaceTexture?.release() - surfaceTexture = null - callbackThread.quitSafely() - } - - fun awaitNewImage() { - val timeoutMs = 2500L - synchronized(frameSyncObject) { - while (!frameAvailable) { - frameSyncObject.wait(timeoutMs) - if (!frameAvailable) { - throw RuntimeException("Surface frame wait timed out") - } - } - frameAvailable = false - } - surfaceTexture!!.updateTexImage() - } - - fun drawImage() { - surfaceTexture!!.getTransformMatrix(stMatrix) - renderer.drawFrame(stMatrix) - } - - override fun onFrameAvailable(st: SurfaceTexture) { - synchronized(frameSyncObject) { - frameAvailable = true - frameSyncObject.notifyAll() - } - } -} diff --git a/modules/expo-bluesky-video-compress/android/src/main/java/expo/modules/blueskyvideocompress/TextureRenderer.kt b/modules/expo-bluesky-video-compress/android/src/main/java/expo/modules/blueskyvideocompress/TextureRenderer.kt deleted file mode 100644 index a0cb25bce1..0000000000 --- a/modules/expo-bluesky-video-compress/android/src/main/java/expo/modules/blueskyvideocompress/TextureRenderer.kt +++ /dev/null @@ -1,156 +0,0 @@ -package expo.modules.blueskyvideocompress - -import android.opengl.GLES11Ext -import android.opengl.GLES20 -import android.opengl.Matrix -import java.nio.ByteBuffer -import java.nio.ByteOrder -import java.nio.FloatBuffer - -class TextureRenderer { - companion object { - private const val FLOAT_SIZE_BYTES = 4 - private const val STRIDE_BYTES = 5 * FLOAT_SIZE_BYTES - private const val POS_OFFSET = 0 - private const val UV_OFFSET = 3 - - private val VERTICES = floatArrayOf( - -1.0f, -1.0f, 0f, 0f, 0f, - 1.0f, -1.0f, 0f, 1f, 0f, - -1.0f, 1.0f, 0f, 0f, 1f, - 1.0f, 1.0f, 0f, 1f, 1f, - ) - - private const val VERTEX_SHADER = """ - uniform mat4 uMVPMatrix; - uniform mat4 uSTMatrix; - attribute vec4 aPosition; - attribute vec4 aTextureCoord; - varying vec2 vTextureCoord; - void main() { - gl_Position = uMVPMatrix * aPosition; - vTextureCoord = (uSTMatrix * aTextureCoord).xy; - } - """ - - private const val FRAGMENT_SHADER = """ - #extension GL_OES_EGL_image_external : require - precision mediump float; - varying vec2 vTextureCoord; - uniform samplerExternalOES sTexture; - void main() { - gl_FragColor = texture2D(sTexture, vTextureCoord); - } - """ - } - - private val vertices: FloatBuffer = - ByteBuffer.allocateDirect(VERTICES.size * FLOAT_SIZE_BYTES) - .order(ByteOrder.nativeOrder()) - .asFloatBuffer() - .apply { put(VERTICES); position(0) } - - private val mvpMatrix = FloatArray(16) - private var program = 0 - private var textureId = -1 - private var uMVPMatrixHandle = 0 - private var uSTMatrixHandle = 0 - private var aPositionHandle = 0 - private var aTextureCoordHandle = 0 - - init { - Matrix.setIdentityM(mvpMatrix, 0) - } - - fun getTextureId(): Int = textureId - - fun surfaceCreated() { - program = createProgram(VERTEX_SHADER, FRAGMENT_SHADER) - - aPositionHandle = GLES20.glGetAttribLocation(program, "aPosition") - aTextureCoordHandle = GLES20.glGetAttribLocation(program, "aTextureCoord") - uMVPMatrixHandle = GLES20.glGetUniformLocation(program, "uMVPMatrix") - uSTMatrixHandle = GLES20.glGetUniformLocation(program, "uSTMatrix") - - val textures = IntArray(1) - GLES20.glGenTextures(1, textures, 0) - textureId = textures[0] - - GLES20.glBindTexture(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, textureId) - GLES20.glTexParameterf( - GLES11Ext.GL_TEXTURE_EXTERNAL_OES, - GLES20.GL_TEXTURE_MIN_FILTER, GLES20.GL_LINEAR.toFloat() - ) - GLES20.glTexParameterf( - GLES11Ext.GL_TEXTURE_EXTERNAL_OES, - GLES20.GL_TEXTURE_MAG_FILTER, GLES20.GL_LINEAR.toFloat() - ) - GLES20.glTexParameteri( - GLES11Ext.GL_TEXTURE_EXTERNAL_OES, - GLES20.GL_TEXTURE_WRAP_S, GLES20.GL_CLAMP_TO_EDGE - ) - GLES20.glTexParameteri( - GLES11Ext.GL_TEXTURE_EXTERNAL_OES, - GLES20.GL_TEXTURE_WRAP_T, GLES20.GL_CLAMP_TO_EDGE - ) - } - - fun drawFrame(stMatrix: FloatArray) { - GLES20.glClearColor(0f, 0f, 0f, 1f) - GLES20.glClear(GLES20.GL_DEPTH_BUFFER_BIT or GLES20.GL_COLOR_BUFFER_BIT) - - GLES20.glUseProgram(program) - GLES20.glActiveTexture(GLES20.GL_TEXTURE0) - GLES20.glBindTexture(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, textureId) - - vertices.position(POS_OFFSET) - GLES20.glVertexAttribPointer( - aPositionHandle, 3, GLES20.GL_FLOAT, false, STRIDE_BYTES, vertices - ) - GLES20.glEnableVertexAttribArray(aPositionHandle) - - vertices.position(UV_OFFSET) - GLES20.glVertexAttribPointer( - aTextureCoordHandle, 2, GLES20.GL_FLOAT, false, STRIDE_BYTES, vertices - ) - GLES20.glEnableVertexAttribArray(aTextureCoordHandle) - - GLES20.glUniformMatrix4fv(uMVPMatrixHandle, 1, false, mvpMatrix, 0) - GLES20.glUniformMatrix4fv(uSTMatrixHandle, 1, false, stMatrix, 0) - - GLES20.glDrawArrays(GLES20.GL_TRIANGLE_STRIP, 0, 4) - GLES20.glDisableVertexAttribArray(aPositionHandle) - GLES20.glDisableVertexAttribArray(aTextureCoordHandle) - } - - private fun createProgram(vertexSource: String, fragmentSource: String): Int { - val vertexShader = loadShader(GLES20.GL_VERTEX_SHADER, vertexSource) - val fragmentShader = loadShader(GLES20.GL_FRAGMENT_SHADER, fragmentSource) - val program = GLES20.glCreateProgram() - GLES20.glAttachShader(program, vertexShader) - GLES20.glAttachShader(program, fragmentShader) - GLES20.glLinkProgram(program) - val linkStatus = IntArray(1) - GLES20.glGetProgramiv(program, GLES20.GL_LINK_STATUS, linkStatus, 0) - if (linkStatus[0] != GLES20.GL_TRUE) { - val log = GLES20.glGetProgramInfoLog(program) - GLES20.glDeleteProgram(program) - throw RuntimeException("Could not link program: $log") - } - return program - } - - private fun loadShader(type: Int, source: String): Int { - val shader = GLES20.glCreateShader(type) - GLES20.glShaderSource(shader, source) - GLES20.glCompileShader(shader) - val compiled = IntArray(1) - GLES20.glGetShaderiv(shader, GLES20.GL_COMPILE_STATUS, compiled, 0) - if (compiled[0] == 0) { - val log = GLES20.glGetShaderInfoLog(shader) - GLES20.glDeleteShader(shader) - throw RuntimeException("Could not compile shader $type: $log") - } - return shader - } -} diff --git a/modules/expo-bluesky-video-compress/android/src/main/java/expo/modules/blueskyvideocompress/VideoCompressor.kt b/modules/expo-bluesky-video-compress/android/src/main/java/expo/modules/blueskyvideocompress/VideoCompressor.kt deleted file mode 100644 index e9c99bc9c4..0000000000 --- a/modules/expo-bluesky-video-compress/android/src/main/java/expo/modules/blueskyvideocompress/VideoCompressor.kt +++ /dev/null @@ -1,582 +0,0 @@ -package expo.modules.blueskyvideocompress - -import android.content.Context -import android.media.MediaCodec -import android.media.MediaCodecInfo -import android.media.MediaExtractor -import android.media.MediaFormat -import android.media.MediaMuxer -import android.net.Uri -import android.os.Build -import android.util.Log -import java.io.File -import java.nio.ByteBuffer -import java.util.UUID - -class VideoCompressor( - private val context: Context, - private val uriString: String, - private val targetBitrate: Int, - private val maxSize: Int, - private val codecPref: String, - private val frameRateCap: Int, - private val jobId: Int, - private val onProgress: (Int, Double) -> Unit -) { - companion object { - private const val TAG = "BskyVideoCompress" - private const val TIMEOUT_DEQUEUE = 100L - private const val I_FRAME_INTERVAL = 3 - } - - @Volatile - private var isCancelled = false - - fun cancel() { - isCancelled = true - } - - fun compress(): Map { - // 'auto' targets h264 — server pipeline is HLS, which favors h264 (HEVC needs - // fMP4 segments + commercial licensing). HEVC remains opt-in via codec: 'hevc'. - val tryHevc = codecPref == "hevc" - - if (tryHevc) { - try { - return doCompress(useHevc = true, allowSoftwareFallback = false) - } catch (e: Exception) { - if (codecPref == "hevc" || isCancelled) throw e - Log.w(TAG, "HEVC encode failed, falling back to h264", e) - } - } - return doCompress(useHevc = false, allowSoftwareFallback = true) - } - - private fun doCompress(useHevc: Boolean, allowSoftwareFallback: Boolean): Map { - val mime = if (useHevc) MediaFormat.MIMETYPE_VIDEO_HEVC else MediaFormat.MIMETYPE_VIDEO_AVC - val encoderInfo = CodecSelector.findEncoder(mime, requireHardware = !allowSoftwareFallback) - ?: throw RuntimeException("No encoder for $mime") - - try { - return runPipeline(encoderInfo, useHevc) - } catch (e: Exception) { - if (!allowSoftwareFallback || isCancelled || !encoderInfo.isHardware) throw e - Log.w(TAG, "Hardware encoder ${encoderInfo.name} failed, trying software", e) - val sw = CodecSelector.findEncoder(mime, requireHardware = false) - ?.takeIf { !it.isHardware } - ?: throw e - return runPipeline(sw, useHevc) - } - } - - private fun runPipeline( - encoderInfo: CodecSelector.EncoderInfo, - useHevc: Boolean - ): Map { - val outputFile = File(context.cacheDir, "${UUID.randomUUID()}.mp4") - - var extractor: MediaExtractor? = null - var muxer: MediaMuxer? = null - var encoder: MediaCodec? = null - var decoder: MediaCodec? = null - var inputSurface: InputSurface? = null - var outputSurface: OutputSurface? = null - var muxerStarted = false - var outputDims = Pair(0, 0) - var durationUs = 0L - - try { - val uri = Uri.parse(uriString) - extractor = MediaExtractor() - if (uriString.startsWith("content://") || uriString.startsWith("file://")) { - extractor.setDataSource(context, uri, null) - } else { - extractor.setDataSource(uriString) - } - - var videoTrackIndex = -1 - var audioTrackIndex = -1 - var videoFormat: MediaFormat? = null - var audioFormat: MediaFormat? = null - - for (i in 0 until extractor.trackCount) { - val format = extractor.getTrackFormat(i) - val trackMime = format.getString(MediaFormat.KEY_MIME) ?: continue - if (trackMime.startsWith("video/") && videoTrackIndex == -1) { - videoTrackIndex = i - videoFormat = format - } else if (trackMime.startsWith("audio/") && audioTrackIndex == -1) { - audioTrackIndex = i - audioFormat = format - } - } - - if (videoTrackIndex == -1 || videoFormat == null) { - throw RuntimeException("No video track found") - } - - val sourceWidth = videoFormat.getInteger(MediaFormat.KEY_WIDTH) - val sourceHeight = videoFormat.getInteger(MediaFormat.KEY_HEIGHT) - val rotation = if (videoFormat.containsKey(MediaFormat.KEY_ROTATION)) { - videoFormat.getInteger(MediaFormat.KEY_ROTATION) - } else 0 - durationUs = if (videoFormat.containsKey(MediaFormat.KEY_DURATION)) { - videoFormat.getLong(MediaFormat.KEY_DURATION) - } else 0L - val sourceFps = if (videoFormat.containsKey(MediaFormat.KEY_FRAME_RATE)) { - videoFormat.getInteger(MediaFormat.KEY_FRAME_RATE) - } else 30 - - outputDims = calculateOutputDims(sourceWidth, sourceHeight, rotation, maxSize) - val shouldPassthroughAudio = audioFormat != null && canPassthroughAudio(audioFormat) - val transcodedAudio: TranscodedAudio? = if ( - audioTrackIndex >= 0 && audioFormat != null && !shouldPassthroughAudio - ) { - transcodeAudioToAAC(audioTrackIndex, audioFormat) - } else null - muxer = MediaMuxer(outputFile.absolutePath, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4) - - val effectiveBitrate = if (targetBitrate > 0) { - targetBitrate - } else if (useHevc) 2_500_000 else 3_000_000 - - val encoderFormat = MediaFormat.createVideoFormat( - encoderInfo.mime, outputDims.first, outputDims.second - ).apply { - setInteger( - MediaFormat.KEY_COLOR_FORMAT, - MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface - ) - setInteger(MediaFormat.KEY_BIT_RATE, effectiveBitrate) - setInteger( - MediaFormat.KEY_BITRATE_MODE, - MediaCodecInfo.EncoderCapabilities.BITRATE_MODE_CBR - ) - setInteger(MediaFormat.KEY_FRAME_RATE, frameRateCap) - setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, I_FRAME_INTERVAL) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { - setInteger(MediaFormat.KEY_COLOR_STANDARD, MediaFormat.COLOR_STANDARD_BT709) - setInteger(MediaFormat.KEY_COLOR_TRANSFER, MediaFormat.COLOR_TRANSFER_SDR_VIDEO) - setInteger(MediaFormat.KEY_COLOR_RANGE, MediaFormat.COLOR_RANGE_LIMITED) - } - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - setInteger(MediaFormat.KEY_PRIORITY, 0) - setInteger(MediaFormat.KEY_OPERATING_RATE, frameRateCap) - if (useHevc) { - setInteger( - MediaFormat.KEY_PROFILE, - MediaCodecInfo.CodecProfileLevel.HEVCProfileMain - ) - } else { - setInteger( - MediaFormat.KEY_PROFILE, - MediaCodecInfo.CodecProfileLevel.AVCProfileHigh - ) - setInteger( - MediaFormat.KEY_LEVEL, - MediaCodecInfo.CodecProfileLevel.AVCLevel41 - ) - } - } - } - - encoder = MediaCodec.createByCodecName(encoderInfo.name) - encoder.configure(encoderFormat, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE) - val encoderInputSurface = encoder.createInputSurface() - inputSurface = InputSurface(encoderInputSurface) - inputSurface.makeCurrent() - outputSurface = OutputSurface() - encoder.start() - - decoder = MediaCodec.createDecoderByType( - videoFormat.getString(MediaFormat.KEY_MIME) ?: "video/avc" - ) - // Ask the decoder to tone-map HDR (HLG/PQ) sources to SDR. Vendors may - // ignore the hint, but where supported it produces correct BT.709 pixels - // for the encoder rather than HDR pixels mislabeled as SDR. - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - videoFormat.setInteger( - MediaFormat.KEY_COLOR_TRANSFER_REQUEST, - MediaFormat.COLOR_TRANSFER_SDR_VIDEO - ) - } - decoder.configure(videoFormat, outputSurface.surface, null, 0) - decoder.start() - extractor.selectTrack(videoTrackIndex) - - val frameDropEnabled = sourceFps > frameRateCap - val targetFrameIntervalUs = if (frameDropEnabled) 1_000_000L / frameRateCap else 0L - var nextTargetPtsUs = 0L - - var muxerVideoTrack = -1 - var muxerAudioTrack = -1 - - val bufferInfo = MediaCodec.BufferInfo() - var inputDone = false - var decoderDone = false - var outputDone = false - var lastProgressMs = 0L - - while (!outputDone && !isCancelled) { - if (!inputDone) { - val idx = decoder.dequeueInputBuffer(TIMEOUT_DEQUEUE) - if (idx >= 0) { - val buf = decoder.getInputBuffer(idx) - if (buf != null) { - val sz = extractor.readSampleData(buf, 0) - if (sz < 0) { - decoder.queueInputBuffer( - idx, 0, 0, 0, MediaCodec.BUFFER_FLAG_END_OF_STREAM - ) - inputDone = true - } else { - decoder.queueInputBuffer(idx, 0, sz, extractor.sampleTime, 0) - extractor.advance() - } - } - } - } - - if (!decoderDone) { - val status = decoder.dequeueOutputBuffer(bufferInfo, TIMEOUT_DEQUEUE) - if (status >= 0) { - val isEos = bufferInfo.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0 - val shouldRender = if (isEos) { - false - } else if (frameDropEnabled) { - if (bufferInfo.presentationTimeUs >= nextTargetPtsUs) { - nextTargetPtsUs = bufferInfo.presentationTimeUs + targetFrameIntervalUs - true - } else false - } else true - decoder.releaseOutputBuffer(status, shouldRender) - if (shouldRender) { - outputSurface.awaitNewImage() - outputSurface.drawImage() - inputSurface.setPresentationTime(bufferInfo.presentationTimeUs * 1000) - inputSurface.swapBuffers() - } - if (isEos) { - encoder.signalEndOfInputStream() - decoderDone = true - } - } - } - - var encoderDrained = false - while (!outputDone && !isCancelled && !encoderDrained) { - val encIdx = encoder.dequeueOutputBuffer(bufferInfo, 0) - when { - encIdx == MediaCodec.INFO_TRY_AGAIN_LATER -> encoderDrained = true - encIdx == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED -> { - if (!muxerStarted) { - muxerVideoTrack = muxer.addTrack(encoder.outputFormat) - if (audioTrackIndex >= 0 && audioFormat != null) { - if (shouldPassthroughAudio) { - muxerAudioTrack = muxer.addTrack(audioFormat) - } else if (transcodedAudio != null) { - muxerAudioTrack = muxer.addTrack(transcodedAudio.outputFormat) - } - } - muxer.start() - muxerStarted = true - } - } - encIdx >= 0 -> { - val data = encoder.getOutputBuffer(encIdx) - if (bufferInfo.flags and MediaCodec.BUFFER_FLAG_CODEC_CONFIG != 0) { - bufferInfo.size = 0 - } - if (data != null && bufferInfo.size > 0 && muxerStarted) { - muxer.writeSampleData(muxerVideoTrack, data, bufferInfo) - } - val isEos = bufferInfo.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0 - encoder.releaseOutputBuffer(encIdx, false) - if (isEos) { - outputDone = true - } else if (durationUs > 0) { - val now = System.currentTimeMillis() - if (now - lastProgressMs >= 100) { - lastProgressMs = now - val p = (bufferInfo.presentationTimeUs.toDouble() / durationUs) - .coerceIn(0.0, 1.0) - onProgress(jobId, p) - } - } - } - } - } - } - - if (audioTrackIndex >= 0 && muxerAudioTrack >= 0 && muxerStarted && !isCancelled) { - if (shouldPassthroughAudio) { - passthroughAudio(audioTrackIndex, muxer, muxerAudioTrack) - } else if (transcodedAudio != null) { - writeTranscodedAudio(transcodedAudio.samples, muxer, muxerAudioTrack) - } - } - } finally { - try { decoder?.stop() } catch (_: Exception) {} - try { decoder?.release() } catch (_: Exception) {} - try { encoder?.stop() } catch (_: Exception) {} - try { encoder?.release() } catch (_: Exception) {} - try { outputSurface?.release() } catch (_: Exception) {} - try { inputSurface?.release() } catch (_: Exception) {} - try { extractor?.release() } catch (_: Exception) {} - try { - if (muxerStarted) muxer?.stop() - muxer?.release() - } catch (_: Exception) {} - } - - if (isCancelled) { - outputFile.delete() - throw RuntimeException("Compression cancelled") - } - - onProgress(jobId, 1.0) - val durationSeconds = durationUs / 1_000_000.0 - - return mapOf( - "uri" to "file://${outputFile.absolutePath}", - "size" to outputFile.length(), - "mimeType" to "video/mp4", - "width" to outputDims.first, - "height" to outputDims.second, - "duration" to durationSeconds, - "codec" to if (useHevc) "hevc" else "h264" - ) - } - - private fun passthroughAudio(audioTrackIndex: Int, muxer: MediaMuxer, muxerAudioTrack: Int) { - val audioExtractor = MediaExtractor() - if (uriString.startsWith("content://") || uriString.startsWith("file://")) { - audioExtractor.setDataSource(context, Uri.parse(uriString), null) - } else { - audioExtractor.setDataSource(uriString) - } - audioExtractor.selectTrack(audioTrackIndex) - audioExtractor.seekTo(0, MediaExtractor.SEEK_TO_CLOSEST_SYNC) - - val buffer = ByteBuffer.allocate(256 * 1024) - val info = MediaCodec.BufferInfo() - try { - while (!isCancelled) { - val sz = audioExtractor.readSampleData(buffer, 0) - if (sz < 0) break - info.offset = 0 - info.size = sz - info.presentationTimeUs = audioExtractor.sampleTime - info.flags = audioExtractor.sampleFlags - muxer.writeSampleData(muxerAudioTrack, buffer, info) - audioExtractor.advance() - } - } finally { - audioExtractor.release() - } - } - - private fun canPassthroughAudio(format: MediaFormat): Boolean { - val mime = format.getString(MediaFormat.KEY_MIME) ?: return false - return mime == MediaFormat.MIMETYPE_AUDIO_AAC - } - - private data class TranscodedAudio( - val outputFormat: MediaFormat, - val samples: List - ) { - data class Sample( - val bytes: ByteArray, - val presentationTimeUs: Long, - val flags: Int - ) - } - - // Re-encode non-AAC source audio (Opus, Vorbis, etc.) to AAC so the mp4 muxer - // can take it. iOS always re-encodes to AAC; without this Android would drop - // the audio track entirely. - private fun transcodeAudioToAAC( - audioTrackIndex: Int, - sourceFormat: MediaFormat - ): TranscodedAudio? { - val sourceMime = sourceFormat.getString(MediaFormat.KEY_MIME) ?: return null - val sampleRate = if (sourceFormat.containsKey(MediaFormat.KEY_SAMPLE_RATE)) - sourceFormat.getInteger(MediaFormat.KEY_SAMPLE_RATE) else 44100 - val channelCount = if (sourceFormat.containsKey(MediaFormat.KEY_CHANNEL_COUNT)) - sourceFormat.getInteger(MediaFormat.KEY_CHANNEL_COUNT).coerceIn(1, 2) else 2 - - val audioExtractor = MediaExtractor() - if (uriString.startsWith("content://") || uriString.startsWith("file://")) { - audioExtractor.setDataSource(context, Uri.parse(uriString), null) - } else { - audioExtractor.setDataSource(uriString) - } - audioExtractor.selectTrack(audioTrackIndex) - - var decoder: MediaCodec? = null - var encoder: MediaCodec? = null - try { - decoder = MediaCodec.createDecoderByType(sourceMime) - decoder.configure(sourceFormat, null, null, 0) - decoder.start() - - val encoderFormat = MediaFormat.createAudioFormat( - MediaFormat.MIMETYPE_AUDIO_AAC, sampleRate, channelCount - ).apply { - setInteger( - MediaFormat.KEY_AAC_PROFILE, - MediaCodecInfo.CodecProfileLevel.AACObjectLC - ) - setInteger(MediaFormat.KEY_BIT_RATE, 128_000) - setInteger(MediaFormat.KEY_MAX_INPUT_SIZE, 256 * 1024) - } - encoder = MediaCodec.createEncoderByType(MediaFormat.MIMETYPE_AUDIO_AAC) - encoder.configure(encoderFormat, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE) - encoder.start() - - val samples = mutableListOf() - var outputFormat: MediaFormat? = null - var inputDone = false - var decoderDone = false - var encoderInputSignalled = false - var encoderDone = false - val info = MediaCodec.BufferInfo() - - while (!encoderDone && !isCancelled) { - if (!inputDone) { - val idx = decoder.dequeueInputBuffer(TIMEOUT_DEQUEUE) - if (idx >= 0) { - val buf = decoder.getInputBuffer(idx) - if (buf != null) { - val sz = audioExtractor.readSampleData(buf, 0) - if (sz < 0) { - decoder.queueInputBuffer( - idx, 0, 0, 0, MediaCodec.BUFFER_FLAG_END_OF_STREAM - ) - inputDone = true - } else { - decoder.queueInputBuffer( - idx, 0, sz, audioExtractor.sampleTime, audioExtractor.sampleFlags - ) - audioExtractor.advance() - } - } - } - } - - if (!decoderDone) { - val status = decoder.dequeueOutputBuffer(info, TIMEOUT_DEQUEUE) - when { - status == MediaCodec.INFO_TRY_AGAIN_LATER -> {} - status == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED -> {} - status >= 0 -> { - val isEos = info.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0 - val data = decoder.getOutputBuffer(status) - if (data != null && info.size > 0) { - val encInIdx = encoder.dequeueInputBuffer(TIMEOUT_DEQUEUE) - if (encInIdx >= 0) { - val encInBuf = encoder.getInputBuffer(encInIdx) - if (encInBuf != null) { - encInBuf.clear() - data.position(info.offset) - data.limit(info.offset + info.size) - encInBuf.put(data) - encoder.queueInputBuffer( - encInIdx, 0, info.size, info.presentationTimeUs, 0 - ) - } - } - } - decoder.releaseOutputBuffer(status, false) - if (isEos) { - if (!encoderInputSignalled) { - val encInIdx = encoder.dequeueInputBuffer(TIMEOUT_DEQUEUE * 10) - if (encInIdx >= 0) { - encoder.queueInputBuffer( - encInIdx, 0, 0, 0, MediaCodec.BUFFER_FLAG_END_OF_STREAM - ) - encoderInputSignalled = true - } - } - decoderDone = true - } - } - } - } - - val encOutIdx = encoder.dequeueOutputBuffer(info, TIMEOUT_DEQUEUE) - when { - encOutIdx == MediaCodec.INFO_TRY_AGAIN_LATER -> {} - encOutIdx == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED -> { - outputFormat = encoder.outputFormat - } - encOutIdx >= 0 -> { - val data = encoder.getOutputBuffer(encOutIdx) - val isEos = info.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0 - val isConfig = info.flags and MediaCodec.BUFFER_FLAG_CODEC_CONFIG != 0 - if (data != null && info.size > 0 && !isConfig) { - val bytes = ByteArray(info.size) - data.position(info.offset) - data.get(bytes, 0, info.size) - samples.add( - TranscodedAudio.Sample( - bytes = bytes, - presentationTimeUs = info.presentationTimeUs, - flags = info.flags and MediaCodec.BUFFER_FLAG_CODEC_CONFIG.inv() - ) - ) - } - encoder.releaseOutputBuffer(encOutIdx, false) - if (isEos) encoderDone = true - } - } - } - - val fmt = outputFormat ?: return null - return TranscodedAudio(fmt, samples) - } catch (e: Exception) { - Log.w(TAG, "Audio transcode failed; dropping audio", e) - return null - } finally { - try { decoder?.stop() } catch (_: Exception) {} - try { decoder?.release() } catch (_: Exception) {} - try { encoder?.stop() } catch (_: Exception) {} - try { encoder?.release() } catch (_: Exception) {} - audioExtractor.release() - } - } - - private fun writeTranscodedAudio( - samples: List, - muxer: MediaMuxer, - muxerAudioTrack: Int - ) { - val info = MediaCodec.BufferInfo() - for (sample in samples) { - if (isCancelled) break - val buffer = ByteBuffer.wrap(sample.bytes) - info.offset = 0 - info.size = sample.bytes.size - info.presentationTimeUs = sample.presentationTimeUs - info.flags = sample.flags - muxer.writeSampleData(muxerAudioTrack, buffer, info) - } - } - - private fun calculateOutputDims(srcW: Int, srcH: Int, rotation: Int, maxSize: Int): Pair { - val isRotated = rotation == 90 || rotation == 270 - val displayW = if (isRotated) srcH else srcW - val displayH = if (isRotated) srcW else srcH - val scale: Float = when { - displayW <= maxSize && displayH <= maxSize -> 1.0f - displayW > displayH -> maxSize.toFloat() / displayW - else -> maxSize.toFloat() / displayH - } - return Pair( - roundToEven((displayW * scale).toInt()), - roundToEven((displayH * scale).toInt()) - ) - } - - private fun roundToEven(v: Int): Int = if (v % 2 == 0) v else v - 1 -} diff --git a/modules/expo-bluesky-video-compress/android/src/main/java/expo/modules/blueskyvideocompress/VideoProber.kt b/modules/expo-bluesky-video-compress/android/src/main/java/expo/modules/blueskyvideocompress/VideoProber.kt deleted file mode 100644 index 7c214bddd0..0000000000 --- a/modules/expo-bluesky-video-compress/android/src/main/java/expo/modules/blueskyvideocompress/VideoProber.kt +++ /dev/null @@ -1,125 +0,0 @@ -package expo.modules.blueskyvideocompress - -import android.content.Context -import android.media.MediaExtractor -import android.media.MediaFormat -import android.media.MediaMetadataRetriever -import android.net.Uri -import android.os.Build - -object VideoProber { - fun probe(context: Context, uriString: String): Map { - val uri = Uri.parse(uriString) - val retriever = MediaMetadataRetriever() - - try { - if (uriString.startsWith("content://") || uriString.startsWith("file://")) { - retriever.setDataSource(context, uri) - } else { - retriever.setDataSource(uriString) - } - - val width = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH) - ?.toIntOrNull() ?: 0 - val height = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT) - ?.toIntOrNull() ?: 0 - val durationMs = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION) - ?.toLongOrNull() ?: 0L - val rotation = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION) - ?.toIntOrNull() ?: 0 - val bitrate = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_BITRATE) - ?.toIntOrNull() ?: 0 - val hasAudio = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_HAS_AUDIO) - ?.equals("yes") ?: false - val frameRate = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_CAPTURE_FRAMERATE) - ?.toFloatOrNull() ?: 0f - - val fileSize = getFileSize(context, uriString) - - val extractor = MediaExtractor() - var codec = "unknown" - var mimeType = "video/mp4" - var extractedFrameRate = frameRate - var isHDR = false - - try { - if (uriString.startsWith("content://") || uriString.startsWith("file://")) { - extractor.setDataSource(context, uri, null) - } else { - extractor.setDataSource(uriString) - } - - for (i in 0 until extractor.trackCount) { - val format = extractor.getTrackFormat(i) - val mime = format.getString(MediaFormat.KEY_MIME) - if (mime?.startsWith("video/") == true) { - mimeType = mime - codec = mime.removePrefix("video/") - if (format.containsKey(MediaFormat.KEY_FRAME_RATE)) { - extractedFrameRate = format.getInteger(MediaFormat.KEY_FRAME_RATE).toFloat() - } - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && - format.containsKey(MediaFormat.KEY_COLOR_TRANSFER) - ) { - val transfer = format.getInteger(MediaFormat.KEY_COLOR_TRANSFER) - isHDR = transfer == MediaFormat.COLOR_TRANSFER_HLG || - transfer == MediaFormat.COLOR_TRANSFER_ST2084 - } - // Dolby Vision tracks use codec-specific mimes that aren't covered by - // KEY_COLOR_TRANSFER on every device. - if (mime.contains("dolby-vision", ignoreCase = true)) { - isHDR = true - } - break - } - } - } finally { - extractor.release() - } - - val durationSeconds = durationMs / 1000.0 - val effectiveBitrate = if (bitrate > 0) { - bitrate - } else if (durationSeconds > 0 && fileSize > 0) { - (fileSize * 8 / durationSeconds).toInt() - } else { - 0 - } - - return mapOf( - "width" to width, - "height" to height, - "duration" to durationSeconds, - "bitrate" to effectiveBitrate, - "fileSize" to fileSize, - "mimeType" to mimeType, - "codec" to codec, - "hasAudio" to hasAudio, - "frameRate" to extractedFrameRate.toDouble(), - "rotation" to rotation, - "isHDR" to isHDR - ) - } finally { - retriever.release() - } - } - - private fun getFileSize(context: Context, uriString: String): Long { - return try { - if (uriString.startsWith("content://")) { - context.contentResolver.openFileDescriptor(Uri.parse(uriString), "r")?.use { - it.statSize - } ?: 0L - } else { - val path = if (uriString.startsWith("file://")) { - uriString.removePrefix("file://") - } else { - uriString - } - java.io.File(path).length() - } - } catch (_: Exception) { - 0L - } - } -} diff --git a/modules/expo-bluesky-video-compress/expo-module.config.json b/modules/expo-bluesky-video-compress/expo-module.config.json deleted file mode 100644 index c1c24a480d..0000000000 --- a/modules/expo-bluesky-video-compress/expo-module.config.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "platforms": ["ios", "android"], - "ios": { - "modules": ["ExpoBlueskyVideoCompressModule"] - }, - "android": { - "modules": ["expo.modules.blueskyvideocompress.ExpoBlueskyVideoCompressModule"] - } -} diff --git a/modules/expo-bluesky-video-compress/index.ts b/modules/expo-bluesky-video-compress/index.ts deleted file mode 100644 index 423d7f3ac2..0000000000 --- a/modules/expo-bluesky-video-compress/index.ts +++ /dev/null @@ -1,87 +0,0 @@ -import {type EventSubscription} from 'expo-modules-core' - -import NativeModule from './src/ExpoBlueskyVideoCompressModule' -import { - type CodecPreference, - type CompressCallbacks, - type CompressOptions, - type CompressResult, - type VideoMetadata, -} from './src/types' - -export type { - CodecPreference, - CompressCallbacks, - CompressOptions, - CompressResult, - VideoMetadata, -} - -class AbortError extends Error { - name = 'AbortError' - constructor() { - super('Aborted') - } -} - -let jobIdCounter = 0 - -export function probe(uri: string): Promise { - return NativeModule.probe(uri) -} - -export function compress( - uri: string, - options: CompressOptions = {}, - callbacks?: CompressCallbacks, -): Promise { - const jobId = ++jobIdCounter - let subscription: EventSubscription | undefined - - if (callbacks?.signal?.aborted) { - return Promise.reject(new AbortError()) - } - - const nativeOptions = { - targetBitrate: options.targetBitrate ?? 0, - maxSize: options.maxSize ?? 1920, - codec: options.codec ?? 'auto', - frameRateCap: options.frameRateCap ?? 30, - jobId, - } - - return new Promise((resolve, reject) => { - if (callbacks?.onProgress) { - subscription = NativeModule.addListener( - 'onProgress', - (event: {id: number; progress: number}) => { - if (event.id === jobId) { - callbacks.onProgress!(event.progress) - } - }, - ) - } - - const abortHandler = () => { - NativeModule.cancel(jobId) - subscription?.remove() - reject(new AbortError()) - } - - if (callbacks?.signal) { - callbacks.signal.addEventListener('abort', abortHandler, {once: true}) - } - - NativeModule.compress(uri, nativeOptions) - .then(result => { - callbacks?.signal?.removeEventListener('abort', abortHandler) - subscription?.remove() - resolve(result) - }) - .catch((error: unknown) => { - callbacks?.signal?.removeEventListener('abort', abortHandler) - subscription?.remove() - reject(error instanceof Error ? error : new Error(String(error))) - }) - }) -} diff --git a/modules/expo-bluesky-video-compress/ios/CodecCapability.swift b/modules/expo-bluesky-video-compress/ios/CodecCapability.swift deleted file mode 100644 index df95d9b7e8..0000000000 --- a/modules/expo-bluesky-video-compress/ios/CodecCapability.swift +++ /dev/null @@ -1,21 +0,0 @@ -import VideoToolbox - -enum CodecCapability { - static let isHardwareHEVCEncodeAvailable: Bool = { - var encoderListCF: CFArray? - let status = VTCopyVideoEncoderList(nil, &encoderListCF) - guard status == noErr, let encoderList = encoderListCF as? [[String: Any]] else { - return false - } - return encoderList.contains { encoder in - guard let codecTypeValue = encoder[kVTVideoEncoderList_CodecType as String] as? Int, - codecTypeValue == Int(kCMVideoCodecType_HEVC) else { - return false - } - if let isHardware = encoder[kVTVideoEncoderList_IsHardwareAccelerated as String] as? Bool { - return isHardware - } - return true - } - }() -} diff --git a/modules/expo-bluesky-video-compress/ios/ExpoBlueskyVideoCompress.podspec b/modules/expo-bluesky-video-compress/ios/ExpoBlueskyVideoCompress.podspec deleted file mode 100644 index 5bca8f0812..0000000000 --- a/modules/expo-bluesky-video-compress/ios/ExpoBlueskyVideoCompress.podspec +++ /dev/null @@ -1,20 +0,0 @@ -Pod::Spec.new do |s| - s.name = 'ExpoBlueskyVideoCompress' - s.version = '1.0.0' - s.summary = 'Hardware-accelerated video compression for Bluesky' - s.description = 'Hardware-accelerated h264/HEVC video compression using AVAssetReader/Writer and VideoToolbox on iOS' - s.author = '' - s.homepage = 'https://github.com/bluesky-social/social-app' - s.platforms = { :ios => '15.1' } - s.source = { git: '' } - s.static_framework = true - - s.dependency 'ExpoModulesCore' - - s.pod_target_xcconfig = { - 'DEFINES_MODULE' => 'YES', - 'SWIFT_COMPILATION_MODE' => 'wholemodule' - } - - s.source_files = "**/*.{h,m,mm,swift,hpp,cpp}" -end diff --git a/modules/expo-bluesky-video-compress/ios/ExpoBlueskyVideoCompressModule.swift b/modules/expo-bluesky-video-compress/ios/ExpoBlueskyVideoCompressModule.swift deleted file mode 100644 index 3bcea3c9f4..0000000000 --- a/modules/expo-bluesky-video-compress/ios/ExpoBlueskyVideoCompressModule.swift +++ /dev/null @@ -1,74 +0,0 @@ -import AVFoundation -import ExpoModulesCore - -public class ExpoBlueskyVideoCompressModule: Module { - private var activeCompressors: [Int: VideoCompressor] = [:] - private let activeCompressorsLock = NSLock() - - public func definition() -> ModuleDefinition { - Name("ExpoBlueskyVideoCompress") - - Events("onProgress") - - AsyncFunction("probe") { (uri: String) -> [String: Any] in - let url = URL(string: uri) ?? URL(fileURLWithPath: uri) - return try await VideoProber.probe(url: url) - } - - AsyncFunction("compress") { (uri: String, options: [String: Any]) -> [String: Any] in - let url = URL(string: uri) ?? URL(fileURLWithPath: uri) - let targetBitrate = options["targetBitrate"] as? Int ?? 0 - let maxSize = options["maxSize"] as? Int ?? 1920 - let codecPref = options["codec"] as? String ?? "auto" - let frameRateCap = max(1, options["frameRateCap"] as? Int ?? 30) - let jobId = options["jobId"] as? Int ?? 0 - - let compressor = VideoCompressor( - url: url, - targetBitrate: targetBitrate, - maxSize: maxSize, - codecPref: codecPref, - frameRateCap: frameRateCap, - jobId: jobId, - onProgress: { [weak self] id, progress in - self?.sendEvent("onProgress", [ - "id": id, - "progress": progress, - ]) - } - ) - - self.setCompressor(jobId, compressor) - - do { - let result = try await compressor.compress() - self.setCompressor(jobId, nil) - return result - } catch { - self.setCompressor(jobId, nil) - throw error - } - } - - Function("cancel") { (jobId: Int) in - self.cancelCompressor(jobId) - } - } - - private func setCompressor(_ jobId: Int, _ compressor: VideoCompressor?) { - activeCompressorsLock.lock() - defer { activeCompressorsLock.unlock() } - if let compressor = compressor { - activeCompressors[jobId] = compressor - } else { - activeCompressors.removeValue(forKey: jobId) - } - } - - private func cancelCompressor(_ jobId: Int) { - activeCompressorsLock.lock() - let compressor = activeCompressors.removeValue(forKey: jobId) - activeCompressorsLock.unlock() - compressor?.cancel() - } -} diff --git a/modules/expo-bluesky-video-compress/ios/VideoCompressor.swift b/modules/expo-bluesky-video-compress/ios/VideoCompressor.swift deleted file mode 100644 index e4a75398ea..0000000000 --- a/modules/expo-bluesky-video-compress/ios/VideoCompressor.swift +++ /dev/null @@ -1,410 +0,0 @@ -import AVFoundation -import VideoToolbox - -class VideoCompressor { - private let url: URL - private let targetBitrate: Int - private let maxSize: Int - private let codecPref: String - private let frameRateCap: Int - private let jobId: Int - private let onProgress: (Int, Double) -> Void - private var isCancelled = false - - init( - url: URL, - targetBitrate: Int, - maxSize: Int, - codecPref: String, - frameRateCap: Int, - jobId: Int, - onProgress: @escaping (Int, Double) -> Void - ) { - self.url = url - self.targetBitrate = targetBitrate - self.maxSize = maxSize - self.codecPref = codecPref - self.frameRateCap = frameRateCap - self.jobId = jobId - self.onProgress = onProgress - } - - func cancel() { - isCancelled = true - } - - func compress() async throws -> [String: Any] { - let asset = AVURLAsset( - url: url, - options: [AVURLAssetPreferPreciseDurationAndTimingKey: true] - ) - let duration = try await asset.load(.duration) - let totalSeconds = CMTimeGetSeconds(duration) - guard totalSeconds > 0 else { throw err("Invalid video duration", code: 2) } - - let videoTracks = try await asset.loadTracks(withMediaType: .video) - guard let videoTrack = videoTracks.first else { - throw err("No video track found", code: 1) - } - - let naturalSize = try await videoTrack.load(.naturalSize) - let preferredTransform = try await videoTrack.load(.preferredTransform) - - let rotatedRect = CGRect(origin: .zero, size: naturalSize).applying(preferredTransform) - let displaySize = CGSize( - width: abs(rotatedRect.width), - height: abs(rotatedRect.height) - ) - let outputSize = scaleEvenly(displaySize: displaySize, maxSize: maxSize) - - let audioTracks = try await asset.loadTracks(withMediaType: .audio) - - // 'auto' targets h264 — server pipeline is HLS, which favors h264 (HEVC needs - // fMP4 segments + commercial licensing). HEVC remains opt-in via codec: 'hevc'. - let useHEVC: Bool - switch codecPref { - case "hevc": useHEVC = true - default: useHEVC = false - } - let codecType: AVVideoCodecType = useHEVC ? .hevc : .h264 - let profileLevel: String = useHEVC - ? kVTProfileLevel_HEVC_Main_AutoLevel as String - : kVTProfileLevel_H264_High_AutoLevel as String - - let effectiveBitrate = targetBitrate > 0 - ? targetBitrate - : (useHEVC ? 2_500_000 : 3_000_000) - - let outputURL = FileManager.default.temporaryDirectory - .appendingPathComponent(UUID().uuidString) - .appendingPathExtension("mp4") - - let videoComposition = makeRotatingComposition( - videoTrack: videoTrack, - preferredTransform: preferredTransform, - naturalSize: naturalSize, - outputSize: outputSize, - duration: duration - ) - - let reader = try AVAssetReader(asset: asset) - - let videoReaderSettings: [String: Any] = [ - kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32BGRA - ] - let videoReaderOutput = AVAssetReaderVideoCompositionOutput( - videoTracks: [videoTrack], - videoSettings: videoReaderSettings - ) - videoReaderOutput.videoComposition = videoComposition - videoReaderOutput.alwaysCopiesSampleData = false - guard reader.canAdd(videoReaderOutput) else { - throw err("Cannot read video track", code: 3) - } - reader.add(videoReaderOutput) - - var compressionProps: [String: Any] = [ - AVVideoAverageBitRateKey: effectiveBitrate, - AVVideoProfileLevelKey: profileLevel, - AVVideoMaxKeyFrameIntervalKey: max(frameRateCap * 3, 30), - AVVideoExpectedSourceFrameRateKey: frameRateCap, - AVVideoAllowFrameReorderingKey: false, - kVTCompressionPropertyKey_RealTime as String: true, - ] - let peakBytesPerSecond = Int(Double(effectiveBitrate) / 8.0 * 1.5) - compressionProps[kVTCompressionPropertyKey_DataRateLimits as String] = [ - peakBytesPerSecond, 1.0 - ] as CFArray - - let videoColorProps: [String: Any] = [ - AVVideoColorPrimariesKey: AVVideoColorPrimaries_ITU_R_709_2, - AVVideoTransferFunctionKey: AVVideoTransferFunction_ITU_R_709_2, - AVVideoYCbCrMatrixKey: AVVideoYCbCrMatrix_ITU_R_709_2, - ] - - let videoWriterSettings: [String: Any] = [ - AVVideoCodecKey: codecType, - AVVideoWidthKey: outputSize.width, - AVVideoHeightKey: outputSize.height, - AVVideoColorPropertiesKey: videoColorProps, - AVVideoCompressionPropertiesKey: compressionProps, - ] - let videoWriterInput = AVAssetWriterInput( - mediaType: .video, - outputSettings: videoWriterSettings - ) - videoWriterInput.expectsMediaDataInRealTime = false - - let writer = try AVAssetWriter(outputURL: outputURL, fileType: .mp4) - writer.shouldOptimizeForNetworkUse = true - writer.metadata = [] - guard writer.canAdd(videoWriterInput) else { - throw err("Cannot write video track", code: 4) - } - writer.add(videoWriterInput) - - var audioReaderOutput: AVAssetReaderTrackOutput? - var audioWriterInput: AVAssetWriterInput? - if let audioTrack = audioTracks.first { - let audioDecoderSettings: [String: Any] = [ - AVFormatIDKey: kAudioFormatLinearPCM, - AVLinearPCMBitDepthKey: 16, - AVLinearPCMIsFloatKey: false, - AVLinearPCMIsBigEndianKey: false, - AVLinearPCMIsNonInterleaved: false, - ] - let audioOutput = AVAssetReaderTrackOutput( - track: audioTrack, - outputSettings: audioDecoderSettings - ) - audioOutput.alwaysCopiesSampleData = false - if reader.canAdd(audioOutput) { - reader.add(audioOutput) - audioReaderOutput = audioOutput - - let audioEncoderSettings: [String: Any] = [ - AVFormatIDKey: kAudioFormatMPEG4AAC, - AVSampleRateKey: 44100, - AVNumberOfChannelsKey: 2, - AVEncoderBitRateKey: 128_000, - ] - let audioInput = AVAssetWriterInput( - mediaType: .audio, - outputSettings: audioEncoderSettings - ) - audioInput.expectsMediaDataInRealTime = false - if writer.canAdd(audioInput) { - writer.add(audioInput) - audioWriterInput = audioInput - } - } - } - - guard reader.startReading() else { - throw reader.error ?? err("Reader failed to start", code: 8) - } - guard writer.startWriting() else { - throw writer.error ?? err("Writer failed to start", code: 9) - } - writer.startSession(atSourceTime: .zero) - - let minFrameIntervalSeconds = 1.0 / Double(frameRateCap) - let minFrameInterval = CMTime( - seconds: minFrameIntervalSeconds, - preferredTimescale: 600 - ) - - await withTaskGroup(of: Void.self) { group in - group.addTask { [self] in - await processVideoTrack( - readerOutput: videoReaderOutput, - writerInput: videoWriterInput, - totalDuration: totalSeconds, - minFrameInterval: minFrameInterval - ) - } - if let audioOutput = audioReaderOutput, let audioInput = audioWriterInput { - group.addTask { [self] in - await processAudioTrack( - readerOutput: audioOutput, - writerInput: audioInput - ) - } - } - await group.waitForAll() - } - - if isCancelled { - writer.cancelWriting() - try? FileManager.default.removeItem(at: outputURL) - throw err("Compression cancelled", code: 5) - } - - if reader.status == .failed { - let error = reader.error ?? err("Reader failed", code: 6) - writer.cancelWriting() - try? FileManager.default.removeItem(at: outputURL) - throw error - } - - await writer.finishWriting() - - if writer.status == .failed { - let error = writer.error ?? err("Writer failed", code: 7) - try? FileManager.default.removeItem(at: outputURL) - throw error - } - - onProgress(jobId, 1.0) - - let attributes = try FileManager.default.attributesOfItem(atPath: outputURL.path) - let fileSize = attributes[.size] as? Int ?? 0 - - return [ - "uri": outputURL.absoluteString, - "size": fileSize, - "mimeType": "video/mp4", - "width": outputSize.width, - "height": outputSize.height, - "duration": totalSeconds, - "codec": useHEVC ? "hevc" : "h264", - ] - } - - private func processVideoTrack( - readerOutput: AVAssetReaderOutput, - writerInput: AVAssetWriterInput, - totalDuration: Double, - minFrameInterval: CMTime - ) async { - var lastProgressTime: CFAbsoluteTime = 0 - var lastAppendedPTS: CMTime? - var finished = false - - await withCheckedContinuation { (continuation: CheckedContinuation) in - writerInput.requestMediaDataWhenReady( - on: DispatchQueue(label: "com.bsky.videocompress.video") - ) { - let finish = { - if !finished { - finished = true - writerInput.markAsFinished() - continuation.resume() - } - } - while writerInput.isReadyForMoreMediaData { - if finished { return } - if self.isCancelled { - finish() - return - } - guard let sampleBuffer = readerOutput.copyNextSampleBuffer() else { - finish() - return - } - - let pts = CMSampleBufferGetPresentationTimeStamp(sampleBuffer) - if let last = lastAppendedPTS { - let delta = CMTimeSubtract(pts, last) - if CMTimeCompare(delta, minFrameInterval) < 0 { - continue - } - } - lastAppendedPTS = pts - - if !writerInput.append(sampleBuffer) { - finish() - return - } - - let now = CFAbsoluteTimeGetCurrent() - if now - lastProgressTime >= 0.1 { - lastProgressTime = now - let progress = min(CMTimeGetSeconds(pts) / totalDuration, 1.0) - self.onProgress(self.jobId, progress) - } - } - } - } - } - - private func processAudioTrack( - readerOutput: AVAssetReaderOutput, - writerInput: AVAssetWriterInput - ) async { - var finished = false - - await withCheckedContinuation { (continuation: CheckedContinuation) in - writerInput.requestMediaDataWhenReady( - on: DispatchQueue(label: "com.bsky.videocompress.audio") - ) { - let finish = { - if !finished { - finished = true - writerInput.markAsFinished() - continuation.resume() - } - } - while writerInput.isReadyForMoreMediaData { - if finished { return } - if self.isCancelled { - finish() - return - } - guard let sampleBuffer = readerOutput.copyNextSampleBuffer() else { - finish() - return - } - if !writerInput.append(sampleBuffer) { - finish() - return - } - } - } - } - } - - private func makeRotatingComposition( - videoTrack: AVAssetTrack, - preferredTransform: CGAffineTransform, - naturalSize: CGSize, - outputSize: (width: Int, height: Int), - duration: CMTime - ) -> AVMutableVideoComposition { - let composition = AVMutableVideoComposition() - composition.renderSize = CGSize(width: outputSize.width, height: outputSize.height) - composition.frameDuration = CMTime(value: 1, timescale: Int32(frameRateCap)) - - let rotatedRect = CGRect(origin: .zero, size: naturalSize).applying(preferredTransform) - let translate = CGAffineTransform( - translationX: -rotatedRect.minX, - y: -rotatedRect.minY - ) - let displaySize = CGSize( - width: abs(rotatedRect.width), - height: abs(rotatedRect.height) - ) - let scaleX = CGFloat(outputSize.width) / displaySize.width - let scaleY = CGFloat(outputSize.height) / displaySize.height - let scale = CGAffineTransform(scaleX: scaleX, y: scaleY) - let combined = preferredTransform.concatenating(translate).concatenating(scale) - - let layerInstruction = AVMutableVideoCompositionLayerInstruction(assetTrack: videoTrack) - layerInstruction.setTransform(combined, at: .zero) - - let instruction = AVMutableVideoCompositionInstruction() - instruction.timeRange = CMTimeRange(start: .zero, duration: duration) - instruction.layerInstructions = [layerInstruction] - - composition.instructions = [instruction] - return composition - } - - private func scaleEvenly(displaySize: CGSize, maxSize: Int) -> (width: Int, height: Int) { - let cap = CGFloat(maxSize) - let scale: CGFloat - if displaySize.width <= cap && displaySize.height <= cap { - scale = 1.0 - } else if displaySize.width > displaySize.height { - scale = cap / displaySize.width - } else { - scale = cap / displaySize.height - } - return ( - roundToEven(Int(displaySize.width * scale)), - roundToEven(Int(displaySize.height * scale)) - ) - } - - private func roundToEven(_ value: Int) -> Int { - return value % 2 == 0 ? value : value - 1 - } - - private func err(_ message: String, code: Int) -> NSError { - return NSError( - domain: "ExpoBlueskyVideoCompress", - code: code, - userInfo: [NSLocalizedDescriptionKey: message] - ) - } -} diff --git a/modules/expo-bluesky-video-compress/ios/VideoProber.swift b/modules/expo-bluesky-video-compress/ios/VideoProber.swift deleted file mode 100644 index 4636ed7d3c..0000000000 --- a/modules/expo-bluesky-video-compress/ios/VideoProber.swift +++ /dev/null @@ -1,105 +0,0 @@ -import AVFoundation -import UniformTypeIdentifiers - -struct VideoProber { - static func probe(url: URL) async throws -> [String: Any] { - let asset = AVURLAsset(url: url) - - let duration = try await asset.load(.duration) - let tracks = try await asset.loadTracks(withMediaType: .video) - - guard let videoTrack = tracks.first else { - throw NSError( - domain: "ExpoBlueskyVideoCompress", - code: 1, - userInfo: [NSLocalizedDescriptionKey: "No video track found"] - ) - } - - let naturalSize = try await videoTrack.load(.naturalSize) - let preferredTransform = try await videoTrack.load(.preferredTransform) - let estimatedDataRate = try await videoTrack.load(.estimatedDataRate) - let nominalFrameRate = try await videoTrack.load(.nominalFrameRate) - let formatDescriptions = try await videoTrack.load(.formatDescriptions) - - var codec = "unknown" - var isHDR = false - if let formatDescription = formatDescriptions.first { - let subType = CMFormatDescriptionGetMediaSubType(formatDescription) - codec = fourCCToString(subType) - // Dolby Vision codecs are HDR by definition. - let dolbyVisionSubtypes: Set = ["dvhe", "dvh1", "dvav", "dva1"] - if dolbyVisionSubtypes.contains(codec) { - isHDR = true - } else if let extensions = CMFormatDescriptionGetExtensions(formatDescription) - as? [String: Any] - { - let transferKey = kCMFormatDescriptionExtension_TransferFunction as String - if let transfer = extensions[transferKey] as? String { - let hlg = kCMFormatDescriptionTransferFunction_ITU_R_2100_HLG as String - let pq = kCMFormatDescriptionTransferFunction_SMPTE_ST_2084_PQ as String - isHDR = transfer == hlg || transfer == pq - } - } - } - - let rotation = rotationFromTransform(preferredTransform) - let isRotated = rotation == 90 || rotation == 270 - let width = isRotated ? Int(naturalSize.height) : Int(naturalSize.width) - let height = isRotated ? Int(naturalSize.width) : Int(naturalSize.height) - - let audioTracks = try await asset.loadTracks(withMediaType: .audio) - let hasAudio = !audioTracks.isEmpty - - let fileSize: Int - if let attributes = try? FileManager.default.attributesOfItem(atPath: url.path), - let size = attributes[.size] as? Int { - fileSize = size - } else { - fileSize = 0 - } - - let mimeType: String - if let utType = UTType(filenameExtension: url.pathExtension) { - mimeType = utType.preferredMIMEType ?? "video/mp4" - } else { - mimeType = "video/mp4" - } - - let durationSeconds = CMTimeGetSeconds(duration) - var bitrate = Int(estimatedDataRate) - if bitrate == 0 && durationSeconds > 0 { - bitrate = Int(Double(fileSize * 8) / durationSeconds) - } - - return [ - "width": width, - "height": height, - "duration": durationSeconds, - "bitrate": bitrate, - "fileSize": fileSize, - "mimeType": mimeType, - "codec": codec, - "hasAudio": hasAudio, - "frameRate": nominalFrameRate, - "rotation": rotation, - "isHDR": isHDR - ] - } - - private static func rotationFromTransform(_ transform: CGAffineTransform) -> Int { - let angle = atan2(transform.b, transform.a) - let degrees = Int(round(angle * 180.0 / .pi)) - return ((degrees % 360) + 360) % 360 - } - - private static func fourCCToString(_ code: FourCharCode) -> String { - let chars: [Character] = [ - Character(UnicodeScalar((code >> 24) & 0xFF)!), - Character(UnicodeScalar((code >> 16) & 0xFF)!), - Character(UnicodeScalar((code >> 8) & 0xFF)!), - Character(UnicodeScalar(code & 0xFF)!) - ] - return String(chars).trimmingCharacters(in: .whitespaces) - } -} diff --git a/modules/expo-bluesky-video-compress/src/ExpoBlueskyVideoCompressModule.ts b/modules/expo-bluesky-video-compress/src/ExpoBlueskyVideoCompressModule.ts deleted file mode 100644 index dcf63c729f..0000000000 --- a/modules/expo-bluesky-video-compress/src/ExpoBlueskyVideoCompressModule.ts +++ /dev/null @@ -1,23 +0,0 @@ -import {type EventSubscription, requireNativeModule} from 'expo-modules-core' - -import { - type CompressResult, - type NativeCompressOptions, - type VideoMetadata, -} from './types' - -type ProgressEvent = {id: number; progress: number} - -interface ExpoBlueskyVideoCompressModule { - probe(uri: string): Promise - compress(uri: string, options: NativeCompressOptions): Promise - cancel(jobId: number): void - addListener( - eventName: 'onProgress', - listener: (event: ProgressEvent) => void, - ): EventSubscription -} - -export default requireNativeModule( - 'ExpoBlueskyVideoCompress', -) diff --git a/modules/expo-bluesky-video-compress/src/types.ts b/modules/expo-bluesky-video-compress/src/types.ts deleted file mode 100644 index df9b9ed1ac..0000000000 --- a/modules/expo-bluesky-video-compress/src/types.ts +++ /dev/null @@ -1,45 +0,0 @@ -export type CodecPreference = 'auto' | 'hevc' | 'h264' - -export type VideoMetadata = { - width: number - height: number - duration: number - bitrate: number - fileSize: number - mimeType: string - codec: string - hasAudio: boolean - frameRate: number - rotation: number - isHDR: boolean -} - -export type CompressOptions = { - targetBitrate?: number - maxSize?: number - codec?: CodecPreference - frameRateCap?: number -} - -export type CompressCallbacks = { - onProgress?: (progress: number) => void - signal?: AbortSignal -} - -export type CompressResult = { - uri: string - size: number - mimeType: string - width: number - height: number - duration: number - codec: 'h264' | 'hevc' -} - -export type NativeCompressOptions = { - targetBitrate: number - maxSize: number - codec: CodecPreference - frameRateCap: number - jobId: number -} diff --git a/package.json b/package.json index a4b4ad4556..32b58d9840 100644 --- a/package.json +++ b/package.json @@ -229,7 +229,6 @@ "react-is": "19", "react-keyed-flatten-children": "^5.0.0", "react-native": "0.86.0", - "react-native-compressor": "1.13.0", "react-native-date-picker": "^5.0.13", "react-native-device-attest": "^0.1.6", "react-native-drawer-layout": "^4.2.3", diff --git a/patches/react-native-compressor@1.13.0.patch b/patches/react-native-compressor@1.13.0.patch deleted file mode 100644 index 1a0f4c61d7..0000000000 --- a/patches/react-native-compressor@1.13.0.patch +++ /dev/null @@ -1,59 +0,0 @@ -diff --git a/android/build.gradle b/android/build.gradle -index 5071139f8ee5fbba085d2afe3b2093de8eda915c..84bee34a238c6510169f6b6bdb0fda0594c77136 100644 ---- a/android/build.gradle -+++ b/android/build.gradle -@@ -115,7 +115,6 @@ dependencies { - implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.4" - implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.4" - implementation 'org.mp4parser:isoparser:1.9.56' -- implementation 'com.github.banketree:AndroidLame-kotlin:v0.0.1' - implementation 'javazoom:jlayer:1.0.1' - } - -diff --git a/android/src/main/java/com/reactnativecompressor/Audio/AudioCompressor.kt b/android/src/main/java/com/reactnativecompressor/Audio/AudioCompressor.kt -deleted file mode 100644 -index 9292d3ee50776bd9d7760b8dcf6d123d44b4e31b..0000000000000000000000000000000000000000 -diff --git a/android/src/main/java/com/reactnativecompressor/Audio/AudioExtractor.kt b/android/src/main/java/com/reactnativecompressor/Audio/AudioExtractor.kt -deleted file mode 100644 -index c6551828014437a14dc8f2f19488b647dba1bbe1..0000000000000000000000000000000000000000 -diff --git a/android/src/main/java/com/reactnativecompressor/Audio/AudioHelper.kt b/android/src/main/java/com/reactnativecompressor/Audio/AudioHelper.kt -deleted file mode 100644 -index 42040b4916573463415ef2f57789b3c4fa25d135..0000000000000000000000000000000000000000 -diff --git a/android/src/main/java/com/reactnativecompressor/Audio/AudioMain.kt b/android/src/main/java/com/reactnativecompressor/Audio/AudioMain.kt -index 446d4fb8b69e7cfdb51b29603aa2d52aac1ab8c8..f02190992dac823b6bbf2d77880a25adc48f16c7 100644 ---- a/android/src/main/java/com/reactnativecompressor/Audio/AudioMain.kt -+++ b/android/src/main/java/com/reactnativecompressor/Audio/AudioMain.kt -@@ -11,7 +11,9 @@ class AudioMain(private val reactContext: ReactApplicationContext) { - promise: Promise) { - try { - -- AudioCompressor.CompressAudio(fileUrl,optionMap,reactContext,promise) -+ // Skip compression on Android to avoid libandroidlame dependency -+ // Return the original file URL without compression -+ promise.resolve(fileUrl) - } catch (ex: Exception) { - promise.reject(ex) - } -diff --git a/android/src/main/java/com/reactnativecompressor/Utils/Utils.kt b/android/src/main/java/com/reactnativecompressor/Utils/Utils.kt -index c14b727e930f4114765bfbe15b742ddcdeaa392f..1198908fcc66eeeea5e537085d7632a0d4b04545 100644 ---- a/android/src/main/java/com/reactnativecompressor/Utils/Utils.kt -+++ b/android/src/main/java/com/reactnativecompressor/Utils/Utils.kt -@@ -7,7 +7,6 @@ import android.provider.OpenableColumns - import android.util.Log - import com.facebook.react.bridge.Promise - import com.facebook.react.bridge.ReactApplicationContext --import com.reactnativecompressor.Audio.AudioCompressor - import com.reactnativecompressor.Video.VideoCompressor.CompressionListener - import com.reactnativecompressor.Video.VideoCompressor.VideoCompressorClass - import java.io.FileNotFoundException -@@ -152,10 +151,6 @@ object Utils { - } - } - -- fun addLog(log: String) { -- Log.d(AudioCompressor.TAG, log) -- } -- - val exifAttributes = arrayOf( - "FNumber", - "ApertureValue", diff --git a/patches/react-native-compressor@1.13.0.patch.md b/patches/react-native-compressor@1.13.0.patch.md deleted file mode 100644 index de59e55244..0000000000 --- a/patches/react-native-compressor@1.13.0.patch.md +++ /dev/null @@ -1,5 +0,0 @@ -# react-native-compressor - -Patch file taken from https://github.com/numandev1/react-native-compressor/pull/355#issuecomment-3180870738 - -This patch removes the audio compression feature on Android from the library. This is because `libandroidlame.so`, the native dependency, does not support 16kb page sizes, and the Play Store has made this mandatory as of 1st Nov 2025. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a763b31309..a959c7d706 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -208,7 +208,6 @@ overrides: '@react-native/normalize-colors': 0.86.0 '@expo/image-utils': 0.8.12 '@types/estree': 1.0.6 - react-native-compressor: 1.13.0 react-native-reanimated: 4.5.3 react-native-worklets: 0.11.3 psl: 1.9.0 @@ -226,7 +225,6 @@ patchedDependencies: expo-notifications@57.0.7: 542d7d2024b364ba601b7f2af041353dccde555db0e6ecf0b5948c90e6b33a75 expo-updates@57.0.10: 04f28cb005b770e9ae8f0065eab96e43cbb1e58107f5f6ad1bdd18f6deb66487 expo@57.0.8: 1722861d12907a2d412d04230545b2fcbfa5547a79f99d763cb3e8de6f3a6f2a - react-native-compressor@1.13.0: 58379dfaace6ced8590cb341c77f2ca8099dfa8f7df6297032ec51de767a9925 react-native-date-picker@5.0.13: 92943fb79d17d7342a29bbb12b0d8ee3cf6f7bca12ed322dc8d124a3e9fb75bd react-native-dotenv@3.4.11: 16b34eb974399935d3cf7c2526534f906991ccd876215176658347059cdd2021 react-native-drawer-layout@4.2.3: 74f2c043cc22ab87054f219e7c7373a509b779b18bfa79d27e7d051d73355130 @@ -632,9 +630,6 @@ importers: react-native: specifier: 0.86.0 version: 0.86.0(patch_hash=dd549527bb84c88acc7b0b1d521c9f4b666fda484c75cd0b282f8e9838524909)(@babel/core@7.29.0(supports-color@8.1.1))(@react-native/jest-preset@0.86.0(@babel/core@7.29.0(supports-color@8.1.1))(react@19.2.3)(supports-color@8.1.1))(@react-native/metro-config@0.86.0(@babel/core@7.29.0(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.18)(react@19.2.3)(supports-color@8.1.1) - react-native-compressor: - specifier: 1.13.0 - version: 1.13.0(patch_hash=58379dfaace6ced8590cb341c77f2ca8099dfa8f7df6297032ec51de767a9925)(react-native@0.86.0(patch_hash=dd549527bb84c88acc7b0b1d521c9f4b666fda484c75cd0b282f8e9838524909)(@babel/core@7.29.0(supports-color@8.1.1))(@react-native/jest-preset@0.86.0(@babel/core@7.29.0(supports-color@8.1.1))(react@19.2.3)(supports-color@8.1.1))(@react-native/metro-config@0.86.0(@babel/core@7.29.0(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.18)(react@19.2.3)(supports-color@8.1.1))(react@19.2.3) react-native-date-picker: specifier: ^5.0.13 version: 5.0.13(patch_hash=92943fb79d17d7342a29bbb12b0d8ee3cf6f7bca12ed322dc8d124a3e9fb75bd)(react-native@0.86.0(patch_hash=dd549527bb84c88acc7b0b1d521c9f4b666fda484c75cd0b282f8e9838524909)(@babel/core@7.29.0(supports-color@8.1.1))(@react-native/jest-preset@0.86.0(@babel/core@7.29.0(supports-color@8.1.1))(react@19.2.3)(supports-color@8.1.1))(@react-native/metro-config@0.86.0(@babel/core@7.29.0(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.18)(react@19.2.3)(supports-color@8.1.1))(react@19.2.3) @@ -8169,13 +8164,6 @@ packages: react: '>=18.0.0' react-is: '>=18.0.0' - react-native-compressor@1.13.0: - resolution: {integrity: sha512-vJYbrcjz2S7kgE3Q7444F71CjEDq5Qg6sGn67N9fJ0I1K6EhB/ZxpULdDe3FxJ4h/ncJc0oxOzNWZhxtGTqcQw==} - engines: {node: '>= 16.0.0'} - peerDependencies: - react: '*' - react-native: '*' - react-native-date-picker@5.0.13: resolution: {integrity: sha512-qCLUODZVsJetO5zuoXjw1D39K527XWqBG8sOfhWdHyPzf13h8RXR1/RSKd1N0fdRDi5GdyizYmB0lPAK12/hbw==} peerDependencies: @@ -18175,11 +18163,6 @@ snapshots: react: 19.2.3 react-is: 19.2.6 - react-native-compressor@1.13.0(patch_hash=58379dfaace6ced8590cb341c77f2ca8099dfa8f7df6297032ec51de767a9925)(react-native@0.86.0(patch_hash=dd549527bb84c88acc7b0b1d521c9f4b666fda484c75cd0b282f8e9838524909)(@babel/core@7.29.0(supports-color@8.1.1))(@react-native/jest-preset@0.86.0(@babel/core@7.29.0(supports-color@8.1.1))(react@19.2.3)(supports-color@8.1.1))(@react-native/metro-config@0.86.0(@babel/core@7.29.0(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.18)(react@19.2.3)(supports-color@8.1.1))(react@19.2.3): - dependencies: - react: 19.2.3 - react-native: 0.86.0(patch_hash=dd549527bb84c88acc7b0b1d521c9f4b666fda484c75cd0b282f8e9838524909)(@babel/core@7.29.0(supports-color@8.1.1))(@react-native/jest-preset@0.86.0(@babel/core@7.29.0(supports-color@8.1.1))(react@19.2.3)(supports-color@8.1.1))(@react-native/metro-config@0.86.0(@babel/core@7.29.0(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.18)(react@19.2.3)(supports-color@8.1.1) - react-native-date-picker@5.0.13(patch_hash=92943fb79d17d7342a29bbb12b0d8ee3cf6f7bca12ed322dc8d124a3e9fb75bd)(react-native@0.86.0(patch_hash=dd549527bb84c88acc7b0b1d521c9f4b666fda484c75cd0b282f8e9838524909)(@babel/core@7.29.0(supports-color@8.1.1))(@react-native/jest-preset@0.86.0(@babel/core@7.29.0(supports-color@8.1.1))(react@19.2.3)(supports-color@8.1.1))(@react-native/metro-config@0.86.0(@babel/core@7.29.0(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.18)(react@19.2.3)(supports-color@8.1.1))(react@19.2.3): dependencies: react: 19.2.3 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index b627e1d3a8..1d06463e71 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -17,7 +17,6 @@ overrides: '@react-native/normalize-colors': '0.86.0' '@expo/image-utils': '0.8.12' '@types/estree': '1.0.6' - 'react-native-compressor': '1.13.0' 'react-native-reanimated': '4.5.3' 'react-native-worklets': '0.11.3' 'psl': '1.9.0' @@ -38,7 +37,6 @@ patchedDependencies: 'expo-notifications@57.0.7': patches/expo-notifications@57.0.7.patch 'expo-updates@57.0.10': patches/expo-updates@57.0.10.patch expo@57.0.8: patches/expo@57.0.8.patch - 'react-native-compressor@1.13.0': patches/react-native-compressor@1.13.0.patch 'react-native-date-picker@5.0.13': patches/react-native-date-picker@5.0.13.patch 'react-native-dotenv@3.4.11': patches/react-native-dotenv@3.4.11.patch 'react-native-drawer-layout@4.2.3': patches/react-native-drawer-layout@4.2.3.patch diff --git a/src/lib/media/video/compress.ts b/src/lib/media/video/compress.ts index d38c1cdecf..47d6e7fb1a 100644 --- a/src/lib/media/video/compress.ts +++ b/src/lib/media/video/compress.ts @@ -44,8 +44,6 @@ export async function compressVideo( } // Pre-check the threshold ourselves so we can label the skip in telemetry. - // rnc would do the same skip internally via minimumFileSizeForCompress, but - // that path is invisible to us. const isAcceptableFormat = SUPPORTED_MIME_TYPES.includes( file.mimeType as SupportedMimeTypes, ) diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx index 4a60efa4f0..465110c45a 100644 --- a/src/view/com/composer/Composer.tsx +++ b/src/view/com/composer/Composer.tsx @@ -408,7 +408,7 @@ export const ComposePost = ({ asset.mimeType !== 'image/gif' ) { try { - const probed = await getVideoMetadata(asset.uri) + const probed = await getVideoMetadata(asset.uri, asset.mimeType) asset = { ...asset, mimeType: probed.mimeType ?? asset.mimeType, @@ -538,8 +538,8 @@ export const ComposePost = ({ let uri = videoInfo.uri if (IS_ANDROID) { // Android: expo-file-system double-encodes filenames with special chars. - // The file exists, but react-native-compressor's MediaMetadataRetriever - // can't handle the double-encoded URI. Copy to a temp file with a simple name. + // The native metadata probe can't handle the double-encoded URI, so + // copy it to a temp file with a simple name. const sourceFile = new FileSystem.File(videoInfo.uri) const tempFileName = `draft-video-${Date.now()}.${mimeToExt(videoInfo.mimeType)}` const tempFile = new FileSystem.File( @@ -553,7 +553,7 @@ export const ComposePost = ({ }) uri = tempFile.uri } - asset = await getVideoMetadata(uri) + asset = await getVideoMetadata(uri, videoInfo.mimeType) } // Start video processing using existing flow diff --git a/src/view/com/composer/videos/VideoTranscodeBackdrop.tsx b/src/view/com/composer/videos/VideoTranscodeBackdrop.tsx index 3daf7954f7..ffa80bc8a6 100644 --- a/src/view/com/composer/videos/VideoTranscodeBackdrop.tsx +++ b/src/view/com/composer/videos/VideoTranscodeBackdrop.tsx @@ -1,6 +1,10 @@ -import {clearCache, createVideoThumbnail} from 'react-native-compressor' import Animated, {FadeIn} from 'react-native-reanimated' +import {File} from 'expo-file-system' import {Image} from 'expo-image' +import { + getThumbnailAsync, + type VideoThumbnailsResult, +} from 'expo-video-thumbnails' import {type QueryClient, useQuery} from '@tanstack/react-query' import {atoms as a} from '#/alf' @@ -8,15 +12,32 @@ import {atoms as a} from '#/alf' export const RQKEY = 'video-thumbnail' export function clearThumbnailCache(queryClient: QueryClient) { - clearCache().catch(() => {}) - void queryClient.resetQueries({queryKey: [RQKEY]}) + for (const [, thumbnail] of queryClient.getQueriesData( + {queryKey: [RQKEY]}, + )) { + if (thumbnail) { + deleteThumbnail(thumbnail) + } + } + queryClient.removeQueries({queryKey: [RQKEY]}) +} + +function deleteThumbnail(thumbnail: VideoThumbnailsResult) { + try { + new File(thumbnail.uri).delete() + } catch {} } export function VideoTranscodeBackdrop({uri}: {uri: string}) { const {data: thumbnail} = useQuery({ queryKey: [RQKEY, uri], - queryFn: async () => { - return await createVideoThumbnail(uri) + queryFn: async ({signal}) => { + const result = await getThumbnailAsync(uri) + if (signal.aborted) { + deleteThumbnail(result) + throw new Error('Thumbnail generation canceled') + } + return result }, }) @@ -25,7 +46,7 @@ export function VideoTranscodeBackdrop({uri}: {uri: string}) { { if (typeof file !== 'string') throw new Error( 'getVideoMetadata was passed a File, when on native it should be a uri', ) - const metadata = await getVideoMetaData(file) + const metadata = await probe(file) return { uri: file, - mimeType: extToMime(metadata.extension), + mimeType: getMimeTypeFromUri(file) ?? fallbackMimeType ?? metadata.mimeType, + fileSize: metadata.fileSize, width: metadata.width, height: metadata.height, /* - * react-native-compressor reports seconds; the rest of the app treats - * `ImagePickerAsset.duration` as milliseconds (matching expo-image-picker). + * The probe reports seconds; `ImagePickerAsset.duration` uses milliseconds. */ duration: metadata.duration * 1000, } } +function getMimeTypeFromUri(uri: string): string | undefined { + const extension = uri.match(/\.([^.?#/]+)(?:[?#]|$)/)?.[1] + if (!extension) return + + try { + return extToMime(extension) + } catch { + return + } +} + export function hasWebCodecs(): boolean { return false } diff --git a/src/view/com/composer/videos/metadata.web.ts b/src/view/com/composer/videos/metadata.web.ts index ed87d157c9..47cf3f9ea1 100644 --- a/src/view/com/composer/videos/metadata.web.ts +++ b/src/view/com/composer/videos/metadata.web.ts @@ -11,6 +11,7 @@ export function hasWebCodecs(): boolean { export async function getVideoMetadata( file: File | string, + _fallbackMimeType?: string, ): Promise { if (typeof file === 'string') throw new Error(