Add expo-bluesky-video-compress native module

Replaces react-native-compressor's video path with a local Expo module.

Native pipeline:
- iOS: AVAssetReader + AVMutableVideoComposition + AVAssetWriter with
  VideoToolbox encode, BGRA reader for HDR -> SDR conversion, BT.709
  color tagging, DataRateLimits hard cap, AAC re-encode.
- Android: MediaExtractor -> MediaCodec(decoder) -> GL pipeline ->
  MediaCodec(encoder) -> MediaMuxer. BITRATE_MODE_CBR for tight target
  enforcement, GL transform-matrix rotation (no double-rotation),
  raw AAC passthrough, hardware encoder selection with software
  fallback, QTI AVC denylist.

Codec selection: 'auto' resolves to h264 (HLS pipeline + licensing).
HEVC remains opt-in via codec: 'hevc' for future feature-flagged use.

compress.ts adds probe-based smart-skip: clips that are already at or
below 5 Mbps / 1920px / 100MB bypass re-encoding entirely.

react-native-compressor stays as a dep for now since pickVideo and
VideoTranscodeBackdrop still use its non-compression helpers; full
removal is a follow-up.
This commit is contained in:
vineyardbovines
2026-06-18 17:38:22 -04:00
parent dd452a4336
commit 3d28b11ae3
18 changed files with 1766 additions and 30 deletions
@@ -0,0 +1,34 @@
apply plugin: 'com.android.library'
group = 'expo.modules.blueskyvideocompress'
version = '1.0.0'
def expoModulesCorePlugin = new File(project(":expo-modules-core").projectDir.absolutePath, "ExpoModulesCorePlugin.gradle")
apply from: expoModulesCorePlugin
applyKotlinExpoModulesCorePlugin()
useCoreDependencies()
useExpoPublishing()
buildscript {
ext.safeExtGet = { prop, fallback ->
rootProject.ext.has(prop) ? rootProject.ext.get(prop) : fallback
}
}
android {
compileSdkVersion safeExtGet("compileSdkVersion", 34)
namespace "expo.modules.blueskyvideocompress"
defaultConfig {
minSdkVersion safeExtGet("minSdkVersion", 21)
targetSdkVersion safeExtGet("targetSdkVersion", 34)
versionCode 1
versionName "1.0.0"
}
lintOptions {
abortOnError false
}
}
dependencies {
}
@@ -0,0 +1,64 @@
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 selectEncoder(preferHevc: Boolean): EncoderInfo? {
if (preferHevc) {
val hevc = findEncoder(MediaFormat.MIMETYPE_VIDEO_HEVC, requireHardware = true)
if (hevc != null) return hevc
}
findEncoder(MediaFormat.MIMETYPE_VIDEO_AVC, requireHardware = true)?.let { return it }
return findEncoder(MediaFormat.MIMETYPE_VIDEO_AVC, requireHardware = false)
}
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) }
}
}
}
@@ -0,0 +1,62 @@
package expo.modules.blueskyvideocompress
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
class ExpoBlueskyVideoCompressModule : Module() {
private var currentCompressor: VideoCompressor? = null
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<String, Any?> ->
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
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
))
}
)
currentCompressor = compressor
try {
val result = compressor.compress()
currentCompressor = null
return@AsyncFunction result
} catch (e: Exception) {
currentCompressor = null
throw e
}
}
Function("cancel") {
currentCompressor?.cancel()
currentCompressor = null
}
}
}
@@ -0,0 +1,96 @@
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<EGLConfig>(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)}")
}
}
}
@@ -0,0 +1,61 @@
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()
}
}
}
@@ -0,0 +1,156 @@
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
}
}
@@ -0,0 +1,372 @@
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
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<String, Any> {
// '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<String, Any> {
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<String, Any> {
val outputFile = File(context.cacheDir, "${System.currentTimeMillis()}.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)
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.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"
)
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 && shouldPassthroughAudio && audioFormat != null) {
muxerAudioTrack = muxer.addTrack(audioFormat)
}
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) {
passthroughAudio(audioTrackIndex, 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 fun calculateOutputDims(srcW: Int, srcH: Int, rotation: Int, maxSize: Int): Pair<Int, Int> {
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
}
@@ -0,0 +1,110 @@
package expo.modules.blueskyvideocompress
import android.content.Context
import android.media.MediaExtractor
import android.media.MediaFormat
import android.media.MediaMetadataRetriever
import android.net.Uri
object VideoProber {
fun probe(context: Context, uriString: String): Map<String, Any> {
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
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()
}
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
)
} 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
}
}
}
@@ -0,0 +1,9 @@
{
"platforms": ["ios", "android"],
"ios": {
"modules": ["ExpoBlueskyVideoCompressModule"]
},
"android": {
"modules": ["expo.modules.blueskyvideocompress.ExpoBlueskyVideoCompressModule"]
}
}
@@ -0,0 +1,87 @@
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<VideoMetadata> {
return NativeModule.probe(uri)
}
export function compress(
uri: string,
options: CompressOptions = {},
callbacks?: CompressCallbacks,
): Promise<CompressResult> {
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<CompressResult>((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()
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)))
})
})
}
@@ -0,0 +1,21 @@
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
}
}()
}
@@ -0,0 +1,20 @@
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
@@ -0,0 +1,57 @@
import AVFoundation
import ExpoModulesCore
public class ExpoBlueskyVideoCompressModule: Module {
private var currentCompressor: VideoCompressor?
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 = 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.currentCompressor = compressor
do {
let result = try await compressor.compress()
self.currentCompressor = nil
return result
} catch {
self.currentCompressor = nil
throw error
}
}
Function("cancel") {
self.currentCompressor?.cancel()
self.currentCompressor = nil
}
}
}
@@ -0,0 +1,397 @@
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
] 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?
await withCheckedContinuation { (continuation: CheckedContinuation<Void, Never>) in
writerInput.requestMediaDataWhenReady(
on: DispatchQueue(label: "com.bsky.videocompress.video")
) {
while writerInput.isReadyForMoreMediaData {
if self.isCancelled {
writerInput.markAsFinished()
continuation.resume()
return
}
guard let sampleBuffer = readerOutput.copyNextSampleBuffer() else {
writerInput.markAsFinished()
continuation.resume()
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) {
writerInput.markAsFinished()
continuation.resume()
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 {
await withCheckedContinuation { (continuation: CheckedContinuation<Void, Never>) in
writerInput.requestMediaDataWhenReady(
on: DispatchQueue(label: "com.bsky.videocompress.audio")
) {
while writerInput.isReadyForMoreMediaData {
if self.isCancelled {
writerInput.markAsFinished()
continuation.resume()
return
}
guard let sampleBuffer = readerOutput.copyNextSampleBuffer() else {
writerInput.markAsFinished()
continuation.resume()
return
}
if !writerInput.append(sampleBuffer) {
writerInput.markAsFinished()
continuation.resume()
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]
)
}
}
@@ -0,0 +1,88 @@
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"
if let formatDescription = formatDescriptions.first {
codec = fourCCToString(CMFormatDescriptionGetMediaSubType(formatDescription))
}
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
]
}
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)
}
}
@@ -0,0 +1,23 @@
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<VideoMetadata>
compress(uri: string, options: NativeCompressOptions): Promise<CompressResult>
cancel(): void
addListener(
eventName: 'onProgress',
listener: (event: ProgressEvent) => void,
): EventSubscription
}
export default requireNativeModule<ExpoBlueskyVideoCompressModule>(
'ExpoBlueskyVideoCompress',
)
@@ -0,0 +1,44 @@
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
}
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
}
+65 -30
View File
@@ -1,11 +1,13 @@
import {getVideoMetaData, Video} from 'react-native-compressor'
import {type ImagePickerAsset} from 'expo-image-picker'
import {SUPPORTED_MIME_TYPES, type SupportedMimeTypes} from '#/lib/constants'
import {logger} from '#/logger'
import {compress, probe} from '../../../../modules/expo-bluesky-video-compress'
import {type CompressedVideo} from './types'
import {extToMime} from './util'
const MIN_SIZE_FOR_COMPRESSION = 25 // 25mb
const PASSTHROUGH_BITRATE = 5_000_000
const PASSTHROUGH_MAX_DIMENSION = 1920
const MAX_UPLOAD_SIZE = 100 * 1000 * 1000
export async function compressVideo(
file: ImagePickerAsset,
@@ -14,43 +16,76 @@ export async function compressVideo(
onProgress?: (progress: number) => void
},
): Promise<CompressedVideo> {
const {onProgress, signal} = opts || {}
if (file.mimeType === 'image/gif') {
return {uri: file.uri, size: file.fileSize ?? -1, mimeType: 'image/gif'}
}
const isAcceptableFormat = SUPPORTED_MIME_TYPES.includes(
file.mimeType as SupportedMimeTypes,
)
if (file.mimeType === 'image/gif') {
// let's hope they're small enough that they don't need compression!
// this compression library doesn't support gifs
// worst case - server rejects them. I think that's fine -sfn
return {uri: file.uri, size: file.fileSize ?? -1, mimeType: 'image/gif'}
const metadata = await probe(file.uri)
if (!shouldCompress(metadata, isAcceptableFormat)) {
return {
uri: file.uri,
size: metadata.fileSize,
mimeType: file.mimeType ?? 'video/mp4',
}
}
const minimumFileSizeForCompress = isAcceptableFormat
? MIN_SIZE_FOR_COMPRESSION
: 0
const compressed = await Video.compress(
const result = await compress(
file.uri,
{
compressionMethod: 'manual',
bitrate: 3_000_000, // 3mbps
maxSize: 1920,
// WARNING: this ONE SPECIFIC ARG is in MB -sfn
minimumFileSizeForCompress,
getCancellationId: id => {
if (signal) {
signal.addEventListener('abort', () => {
Video.cancelCompression(id)
})
}
},
targetBitrate: 3_000_000,
maxSize: PASSTHROUGH_MAX_DIMENSION,
codec: 'h264',
},
{
onProgress: opts?.onProgress,
signal: opts?.signal,
},
onProgress,
)
const info = await getVideoMetaData(compressed)
return {uri: compressed, size: info.size, mimeType: extToMime(info.extension)}
return {
uri: result.uri,
size: result.size,
mimeType: result.mimeType,
}
}
function shouldCompress(
metadata: {bitrate: number; width: number; height: number; fileSize: number},
isAcceptableFormat: boolean,
): boolean {
const maxDimension = Math.max(metadata.width, metadata.height)
const bitrateKbps = Math.round(metadata.bitrate / 1000)
const sizeMB = (metadata.fileSize / 1_000_000).toFixed(1)
if (!isAcceptableFormat) {
logger.debug('shouldCompress: yes (unsupported format)')
return true
}
if (metadata.fileSize > MAX_UPLOAD_SIZE) {
logger.debug(`shouldCompress: yes (file too large: ${sizeMB}MB)`)
return true
}
if (
metadata.bitrate <= PASSTHROUGH_BITRATE &&
maxDimension <= PASSTHROUGH_MAX_DIMENSION
) {
logger.debug(
`shouldCompress: no (${bitrateKbps}kbps, ${maxDimension}px, ${sizeMB}MB)`,
)
return false
}
if (metadata.bitrate > PASSTHROUGH_BITRATE) {
logger.debug(`shouldCompress: yes (bitrate ${bitrateKbps}kbps)`)
} else {
logger.debug(`shouldCompress: yes (dimension ${maxDimension}px)`)
}
return true
}