speed up android signficantly
This commit is contained in:
+103
@@ -0,0 +1,103 @@
|
|||||||
|
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
|
||||||
|
|
||||||
|
/**
|
||||||
|
* EGL14 wrapper around the encoder's input Surface. Provides an EGL context and
|
||||||
|
* window surface so that the GL pipeline can render decoded frames onto the
|
||||||
|
* encoder's input at the target resolution.
|
||||||
|
*/
|
||||||
|
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_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 {
|
||||||
|
val result = EGL14.eglSwapBuffers(eglDisplay, eglSurface)
|
||||||
|
checkEglError("eglSwapBuffers")
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setPresentationTime(nsecs: Long) {
|
||||||
|
EGLExt.eglPresentationTimeANDROID(eglDisplay, eglSurface, nsecs)
|
||||||
|
checkEglError("eglPresentationTimeANDROID")
|
||||||
|
}
|
||||||
|
|
||||||
|
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)}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+80
@@ -0,0 +1,80 @@
|
|||||||
|
package expo.modules.blueskyvideocompress
|
||||||
|
|
||||||
|
import android.graphics.SurfaceTexture
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.HandlerThread
|
||||||
|
import android.view.Surface
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wraps a SurfaceTexture to receive decoded frames and render them via GL.
|
||||||
|
* The decoder outputs to this surface, and drawImage() renders the latest
|
||||||
|
* frame through the TextureRenderer onto the current EGL surface (the encoder's
|
||||||
|
* input via InputSurface).
|
||||||
|
*
|
||||||
|
* Uses a dedicated HandlerThread for frame-available callbacks so they fire
|
||||||
|
* reliably regardless of the calling thread's Looper state.
|
||||||
|
*/
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Blocks until a new frame is available from the decoder (up to 2500ms).
|
||||||
|
*/
|
||||||
|
fun awaitNewImage() {
|
||||||
|
val timeoutMs = 2500L
|
||||||
|
synchronized(frameSyncObject) {
|
||||||
|
while (!frameAvailable) {
|
||||||
|
frameSyncObject.wait(timeoutMs)
|
||||||
|
if (!frameAvailable) {
|
||||||
|
throw RuntimeException("Surface frame wait timed out")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
frameAvailable = false
|
||||||
|
}
|
||||||
|
// Must be called outside synchronized to avoid deadlock with onFrameAvailable
|
||||||
|
surfaceTexture!!.updateTexImage()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders the most recent frame through the GL pipeline.
|
||||||
|
*/
|
||||||
|
fun drawImage() {
|
||||||
|
surfaceTexture!!.getTransformMatrix(stMatrix)
|
||||||
|
renderer.drawFrame(stMatrix)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onFrameAvailable(st: SurfaceTexture) {
|
||||||
|
synchronized(frameSyncObject) {
|
||||||
|
frameAvailable = true
|
||||||
|
frameSyncObject.notifyAll()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+209
@@ -0,0 +1,209 @@
|
|||||||
|
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
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GLES2 renderer that draws an external OES texture (from SurfaceTexture) onto
|
||||||
|
* the current EGL surface. The GL viewport handles scaling from source to target
|
||||||
|
* dimensions automatically with GL_LINEAR filtering.
|
||||||
|
*/
|
||||||
|
class TextureRenderer {
|
||||||
|
companion object {
|
||||||
|
private const val FLOAT_SIZE_BYTES = 4
|
||||||
|
private const val VERTICES_DATA_STRIDE_BYTES = 5 * FLOAT_SIZE_BYTES
|
||||||
|
private const val VERTICES_DATA_POS_OFFSET = 0
|
||||||
|
private const val VERTICES_DATA_UV_OFFSET = 3
|
||||||
|
|
||||||
|
private val VERTICES_DATA = floatArrayOf(
|
||||||
|
// X, Y, Z, U, V
|
||||||
|
-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_DATA.size * FLOAT_SIZE_BYTES)
|
||||||
|
.order(ByteOrder.nativeOrder())
|
||||||
|
.asFloatBuffer()
|
||||||
|
.apply {
|
||||||
|
put(VERTICES_DATA)
|
||||||
|
position(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
private val mvpMatrix = FloatArray(16)
|
||||||
|
private val stMatrix = 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(stMatrix, 0)
|
||||||
|
Matrix.setIdentityM(mvpMatrix, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getTextureId(): Int = textureId
|
||||||
|
|
||||||
|
fun surfaceCreated() {
|
||||||
|
program = createProgram(VERTEX_SHADER, FRAGMENT_SHADER)
|
||||||
|
|
||||||
|
aPositionHandle = GLES20.glGetAttribLocation(program, "aPosition")
|
||||||
|
checkGlError("glGetAttribLocation aPosition")
|
||||||
|
|
||||||
|
aTextureCoordHandle = GLES20.glGetAttribLocation(program, "aTextureCoord")
|
||||||
|
checkGlError("glGetAttribLocation aTextureCoord")
|
||||||
|
|
||||||
|
uMVPMatrixHandle = GLES20.glGetUniformLocation(program, "uMVPMatrix")
|
||||||
|
checkGlError("glGetUniformLocation uMVPMatrix")
|
||||||
|
|
||||||
|
uSTMatrixHandle = GLES20.glGetUniformLocation(program, "uSTMatrix")
|
||||||
|
checkGlError("glGetUniformLocation uSTMatrix")
|
||||||
|
|
||||||
|
// Create OES texture
|
||||||
|
val textures = IntArray(1)
|
||||||
|
GLES20.glGenTextures(1, textures, 0)
|
||||||
|
textureId = textures[0]
|
||||||
|
|
||||||
|
GLES20.glBindTexture(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, textureId)
|
||||||
|
checkGlError("glBindTexture")
|
||||||
|
|
||||||
|
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
|
||||||
|
)
|
||||||
|
checkGlError("glTexParameter")
|
||||||
|
}
|
||||||
|
|
||||||
|
fun drawFrame(stMatrix: FloatArray) {
|
||||||
|
checkGlError("onDrawFrame start")
|
||||||
|
|
||||||
|
GLES20.glClearColor(0f, 0f, 0f, 1f)
|
||||||
|
GLES20.glClear(GLES20.GL_DEPTH_BUFFER_BIT or GLES20.GL_COLOR_BUFFER_BIT)
|
||||||
|
|
||||||
|
GLES20.glUseProgram(program)
|
||||||
|
checkGlError("glUseProgram")
|
||||||
|
|
||||||
|
GLES20.glActiveTexture(GLES20.GL_TEXTURE0)
|
||||||
|
GLES20.glBindTexture(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, textureId)
|
||||||
|
|
||||||
|
// Position
|
||||||
|
vertices.position(VERTICES_DATA_POS_OFFSET)
|
||||||
|
GLES20.glVertexAttribPointer(
|
||||||
|
aPositionHandle, 3, GLES20.GL_FLOAT, false,
|
||||||
|
VERTICES_DATA_STRIDE_BYTES, vertices
|
||||||
|
)
|
||||||
|
checkGlError("glVertexAttribPointer aPosition")
|
||||||
|
GLES20.glEnableVertexAttribArray(aPositionHandle)
|
||||||
|
checkGlError("glEnableVertexAttribArray aPosition")
|
||||||
|
|
||||||
|
// Texture coordinates
|
||||||
|
vertices.position(VERTICES_DATA_UV_OFFSET)
|
||||||
|
GLES20.glVertexAttribPointer(
|
||||||
|
aTextureCoordHandle, 2, GLES20.GL_FLOAT, false,
|
||||||
|
VERTICES_DATA_STRIDE_BYTES, vertices
|
||||||
|
)
|
||||||
|
checkGlError("glVertexAttribPointer aTextureCoord")
|
||||||
|
GLES20.glEnableVertexAttribArray(aTextureCoordHandle)
|
||||||
|
checkGlError("glEnableVertexAttribArray aTextureCoord")
|
||||||
|
|
||||||
|
// Matrices
|
||||||
|
GLES20.glUniformMatrix4fv(uMVPMatrixHandle, 1, false, mvpMatrix, 0)
|
||||||
|
GLES20.glUniformMatrix4fv(uSTMatrixHandle, 1, false, stMatrix, 0)
|
||||||
|
|
||||||
|
GLES20.glDrawArrays(GLES20.GL_TRIANGLE_STRIP, 0, 4)
|
||||||
|
checkGlError("glDrawArrays")
|
||||||
|
|
||||||
|
GLES20.glFinish()
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
checkGlError("glCreateProgram")
|
||||||
|
|
||||||
|
GLES20.glAttachShader(program, vertexShader)
|
||||||
|
checkGlError("glAttachShader vertex")
|
||||||
|
GLES20.glAttachShader(program, fragmentShader)
|
||||||
|
checkGlError("glAttachShader fragment")
|
||||||
|
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(shaderType: Int, source: String): Int {
|
||||||
|
val shader = GLES20.glCreateShader(shaderType)
|
||||||
|
checkGlError("glCreateShader type=$shaderType")
|
||||||
|
|
||||||
|
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 $shaderType: $log")
|
||||||
|
}
|
||||||
|
return shader
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun checkGlError(op: String) {
|
||||||
|
val error = GLES20.glGetError()
|
||||||
|
if (error != GLES20.GL_NO_ERROR) {
|
||||||
|
throw RuntimeException("$op: glError $error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+115
-235
@@ -9,7 +9,6 @@ import android.media.MediaMuxer
|
|||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import android.view.Surface
|
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.nio.ByteBuffer
|
import java.nio.ByteBuffer
|
||||||
|
|
||||||
@@ -23,11 +22,8 @@ class VideoCompressor(
|
|||||||
) {
|
) {
|
||||||
companion object {
|
companion object {
|
||||||
private const val TAG = "VideoCompressor"
|
private const val TAG = "VideoCompressor"
|
||||||
private const val TIMEOUT_US = 10_000L
|
private const val TIMEOUT_DEQUEUE = 100L // 100us
|
||||||
private const val I_FRAME_INTERVAL = 3
|
private const val I_FRAME_INTERVAL = 3
|
||||||
private const val AUDIO_AAC_BITRATE = 128_000
|
|
||||||
private const val AUDIO_SAMPLE_RATE = 44100
|
|
||||||
private const val AUDIO_CHANNELS = 2
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Volatile
|
@Volatile
|
||||||
@@ -156,12 +152,19 @@ class VideoCompressor(
|
|||||||
MediaFormat.KEY_LEVEL,
|
MediaFormat.KEY_LEVEL,
|
||||||
MediaCodecInfo.CodecProfileLevel.AVCLevel41
|
MediaCodecInfo.CodecProfileLevel.AVCLevel41
|
||||||
)
|
)
|
||||||
|
setInteger(MediaFormat.KEY_PRIORITY, 0) // realtime
|
||||||
|
setInteger(MediaFormat.KEY_OPERATING_RATE, Short.MAX_VALUE.toInt()) // max speed
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val encoder = MediaCodec.createByCodecName(encoderName)
|
val encoder = MediaCodec.createByCodecName(encoderName)
|
||||||
encoder.configure(encoderFormat, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE)
|
encoder.configure(encoderFormat, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE)
|
||||||
val inputSurface = encoder.createInputSurface()
|
|
||||||
|
// GL pipeline: Decoder -> SurfaceTexture -> OpenGL ES 2.0 -> EGL Surface -> Encoder
|
||||||
|
val inputSurface = InputSurface(encoder.createInputSurface())
|
||||||
|
inputSurface.makeCurrent()
|
||||||
|
val outputSurface = OutputSurface()
|
||||||
|
|
||||||
encoder.start()
|
encoder.start()
|
||||||
|
|
||||||
// Set up video decoder
|
// Set up video decoder
|
||||||
@@ -169,23 +172,28 @@ class VideoCompressor(
|
|||||||
val decoder = MediaCodec.createDecoderByType(
|
val decoder = MediaCodec.createDecoderByType(
|
||||||
videoFormat.getString(MediaFormat.KEY_MIME) ?: "video/avc"
|
videoFormat.getString(MediaFormat.KEY_MIME) ?: "video/avc"
|
||||||
)
|
)
|
||||||
// Output surface is the encoder's input surface for zero-copy pipeline
|
decoder.configure(decoderFormat, outputSurface.surface, null, 0)
|
||||||
decoder.configure(decoderFormat, inputSurface, null, 0)
|
|
||||||
decoder.start()
|
decoder.start()
|
||||||
|
|
||||||
extractor.selectTrack(videoTrackIndex)
|
extractor.selectTrack(videoTrackIndex)
|
||||||
|
|
||||||
|
// Frame rate capping: drop frames above 30fps
|
||||||
|
val targetFps = frameRate.coerceAtMost(30)
|
||||||
|
val frameIntervalUs = 1_000_000L / targetFps
|
||||||
|
|
||||||
// Process video frames
|
// Process video frames
|
||||||
val bufferInfo = MediaCodec.BufferInfo()
|
val bufferInfo = MediaCodec.BufferInfo()
|
||||||
var inputDone = false
|
var inputDone = false
|
||||||
|
var decoderDone = false
|
||||||
var outputDone = false
|
var outputDone = false
|
||||||
var lastProgressTime = 0L
|
var lastProgressTime = 0L
|
||||||
|
var lastRenderedPtsUs = -1L
|
||||||
|
|
||||||
try {
|
try {
|
||||||
while (!outputDone && !isCancelled) {
|
while (!outputDone && !isCancelled) {
|
||||||
// Feed decoder
|
// Feed decoder
|
||||||
if (!inputDone) {
|
if (!inputDone) {
|
||||||
val inputIndex = decoder.dequeueInputBuffer(TIMEOUT_US)
|
val inputIndex = decoder.dequeueInputBuffer(TIMEOUT_DEQUEUE)
|
||||||
if (inputIndex >= 0) {
|
if (inputIndex >= 0) {
|
||||||
val inputBuffer = decoder.getInputBuffer(inputIndex) ?: continue
|
val inputBuffer = decoder.getInputBuffer(inputIndex) ?: continue
|
||||||
val sampleSize = extractor.readSampleData(inputBuffer, 0)
|
val sampleSize = extractor.readSampleData(inputBuffer, 0)
|
||||||
@@ -205,71 +213,66 @@ class VideoCompressor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Drain decoder -> surface -> encoder
|
// Drain decoder -> GL pipeline -> encoder
|
||||||
drainDecoder(decoder, bufferInfo)
|
if (!decoderDone) {
|
||||||
|
decoderDone = drainDecoder(
|
||||||
|
decoder, encoder, inputSurface, outputSurface,
|
||||||
|
bufferInfo, frameIntervalUs, lastRenderedPtsUs
|
||||||
|
) { pts -> lastRenderedPtsUs = pts }
|
||||||
|
}
|
||||||
|
|
||||||
// Drain encoder
|
// Drain all available encoder output
|
||||||
val encoderOutputIndex = encoder.dequeueOutputBuffer(bufferInfo, TIMEOUT_US)
|
while (!outputDone && !isCancelled) {
|
||||||
when {
|
val encoderOutputIndex = encoder.dequeueOutputBuffer(bufferInfo, TIMEOUT_DEQUEUE)
|
||||||
encoderOutputIndex == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED -> {
|
when {
|
||||||
if (!muxerStarted) {
|
encoderOutputIndex == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED -> {
|
||||||
muxerVideoTrack = muxer.addTrack(encoder.outputFormat)
|
if (!muxerStarted) {
|
||||||
// If we have audio, add it now too before starting muxer
|
muxerVideoTrack = muxer.addTrack(encoder.outputFormat)
|
||||||
if (audioTrackIndex != -1 && audioFormat != null) {
|
// Add audio track for passthrough (format known upfront)
|
||||||
muxerAudioTrack = if (shouldPassthroughAudio) {
|
if (audioTrackIndex != -1 && audioFormat != null && shouldPassthroughAudio) {
|
||||||
muxer.addTrack(audioFormat)
|
muxerAudioTrack = muxer.addTrack(audioFormat)
|
||||||
} else {
|
|
||||||
// Audio re-encode track will be added when audio encoder outputs format
|
|
||||||
-1
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
if (audioTrackIndex == -1 || muxerAudioTrack >= 0) {
|
|
||||||
muxer.start()
|
muxer.start()
|
||||||
muxerStarted = true
|
muxerStarted = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
encoderOutputIndex >= 0 -> {
|
||||||
encoderOutputIndex >= 0 -> {
|
val outputBuffer = encoder.getOutputBuffer(encoderOutputIndex)
|
||||||
val outputBuffer = encoder.getOutputBuffer(encoderOutputIndex)
|
if (outputBuffer != null &&
|
||||||
if (outputBuffer != null &&
|
bufferInfo.flags and MediaCodec.BUFFER_FLAG_CODEC_CONFIG == 0 &&
|
||||||
bufferInfo.flags and MediaCodec.BUFFER_FLAG_CODEC_CONFIG == 0 &&
|
bufferInfo.size > 0 &&
|
||||||
bufferInfo.size > 0 &&
|
muxerStarted) {
|
||||||
muxerStarted) {
|
muxer.writeSampleData(muxerVideoTrack, outputBuffer, bufferInfo)
|
||||||
muxer.writeSampleData(muxerVideoTrack, outputBuffer, bufferInfo)
|
}
|
||||||
}
|
|
||||||
|
|
||||||
val isEos = bufferInfo.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0
|
val isEos = bufferInfo.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0
|
||||||
encoder.releaseOutputBuffer(encoderOutputIndex, false)
|
encoder.releaseOutputBuffer(encoderOutputIndex, false)
|
||||||
|
|
||||||
if (isEos) {
|
if (isEos) {
|
||||||
outputDone = true
|
outputDone = true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Progress reporting
|
// Progress reporting
|
||||||
if (durationUs > 0) {
|
if (durationUs > 0) {
|
||||||
val now = System.currentTimeMillis()
|
val now = System.currentTimeMillis()
|
||||||
if (now - lastProgressTime >= 100) {
|
if (now - lastProgressTime >= 100) {
|
||||||
lastProgressTime = now
|
lastProgressTime = now
|
||||||
val progress = (bufferInfo.presentationTimeUs.toDouble() / durationUs)
|
val progress = (bufferInfo.presentationTimeUs.toDouble() / durationUs)
|
||||||
.coerceIn(0.0, 1.0)
|
.coerceIn(0.0, 1.0)
|
||||||
onProgress(jobId, progress)
|
onProgress(jobId, progress)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
else -> break // No more encoder output available right now
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process audio track
|
// Process audio track (passthrough only — non-AAC audio is skipped)
|
||||||
if (audioTrackIndex != -1 && audioFormat != null && !isCancelled) {
|
if (muxerAudioTrack >= 0 && muxerStarted && !isCancelled) {
|
||||||
if (shouldPassthroughAudio) {
|
processAudioPassthrough(
|
||||||
processAudioPassthrough(
|
audioTrackIndex, muxer, muxerAudioTrack
|
||||||
extractor, audioTrackIndex, muxer, muxerAudioTrack, muxerStarted
|
)
|
||||||
)
|
|
||||||
} else {
|
|
||||||
processAudioReencode(
|
|
||||||
extractor, audioTrackIndex, audioFormat, muxer, muxerStarted
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
// Clean up resources
|
// Clean up resources
|
||||||
@@ -277,6 +280,7 @@ class VideoCompressor(
|
|||||||
try { decoder.release() } catch (_: Exception) {}
|
try { decoder.release() } catch (_: Exception) {}
|
||||||
try { encoder.stop() } catch (_: Exception) {}
|
try { encoder.stop() } catch (_: Exception) {}
|
||||||
try { encoder.release() } catch (_: Exception) {}
|
try { encoder.release() } catch (_: Exception) {}
|
||||||
|
try { outputSurface.release() } catch (_: Exception) {}
|
||||||
try { inputSurface.release() } catch (_: Exception) {}
|
try { inputSurface.release() } catch (_: Exception) {}
|
||||||
try { extractor.release() } catch (_: Exception) {}
|
try { extractor.release() } catch (_: Exception) {}
|
||||||
try {
|
try {
|
||||||
@@ -303,41 +307,66 @@ class VideoCompressor(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun drainDecoder(decoder: MediaCodec, bufferInfo: MediaCodec.BufferInfo) {
|
/**
|
||||||
|
* Drains decoded frames through the GL pipeline to the encoder.
|
||||||
|
* Drops frames to cap at the target frame rate.
|
||||||
|
*
|
||||||
|
* @return true if the decoder signaled EOS (all frames decoded)
|
||||||
|
*/
|
||||||
|
private fun drainDecoder(
|
||||||
|
decoder: MediaCodec,
|
||||||
|
encoder: MediaCodec,
|
||||||
|
inputSurface: InputSurface,
|
||||||
|
outputSurface: OutputSurface,
|
||||||
|
bufferInfo: MediaCodec.BufferInfo,
|
||||||
|
frameIntervalUs: Long,
|
||||||
|
lastRenderedPtsUs: Long,
|
||||||
|
onRendered: (Long) -> Unit
|
||||||
|
): Boolean {
|
||||||
|
var currentLastPts = lastRenderedPtsUs
|
||||||
|
|
||||||
while (true) {
|
while (true) {
|
||||||
val outputIndex = decoder.dequeueOutputBuffer(bufferInfo, TIMEOUT_US)
|
val outputIndex = decoder.dequeueOutputBuffer(bufferInfo, TIMEOUT_DEQUEUE)
|
||||||
if (outputIndex < 0) break
|
if (outputIndex < 0) break
|
||||||
|
|
||||||
val isEos = bufferInfo.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0
|
val isEos = bufferInfo.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0
|
||||||
// Render to surface (encoder's input) - true means render
|
|
||||||
decoder.releaseOutputBuffer(outputIndex, bufferInfo.size > 0)
|
|
||||||
|
|
||||||
if (isEos) {
|
if (!isEos) {
|
||||||
// Signal encoder that input is done
|
// Frame rate capping: decide whether to render or drop this frame
|
||||||
// Note: with Surface input, we signal EOS by calling signalEndOfInputStream
|
val shouldRender = if (currentLastPts < 0) {
|
||||||
break
|
true
|
||||||
|
} else {
|
||||||
|
bufferInfo.presentationTimeUs - currentLastPts >= frameIntervalUs
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shouldRender && bufferInfo.size > 0) {
|
||||||
|
// Render through GL pipeline: decoder -> SurfaceTexture -> GL -> encoder
|
||||||
|
decoder.releaseOutputBuffer(outputIndex, true)
|
||||||
|
outputSurface.awaitNewImage()
|
||||||
|
outputSurface.drawImage()
|
||||||
|
inputSurface.setPresentationTime(bufferInfo.presentationTimeUs * 1000)
|
||||||
|
inputSurface.swapBuffers()
|
||||||
|
currentLastPts = bufferInfo.presentationTimeUs
|
||||||
|
onRendered(currentLastPts)
|
||||||
|
} else {
|
||||||
|
// Drop frame (don't render to surface)
|
||||||
|
decoder.releaseOutputBuffer(outputIndex, false)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
decoder.releaseOutputBuffer(outputIndex, false)
|
||||||
|
encoder.signalEndOfInputStream()
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return false
|
||||||
// If decoder flagged EOS, signal encoder
|
|
||||||
if (bufferInfo.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0) {
|
|
||||||
// This may throw if already signaled - that's fine
|
|
||||||
try {
|
|
||||||
// We need access to encoder here - this is handled in the main loop
|
|
||||||
} catch (_: Exception) {}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun processAudioPassthrough(
|
private fun processAudioPassthrough(
|
||||||
extractor: MediaExtractor,
|
|
||||||
audioTrackIndex: Int,
|
audioTrackIndex: Int,
|
||||||
muxer: MediaMuxer,
|
muxer: MediaMuxer,
|
||||||
muxerAudioTrack: Int,
|
muxerAudioTrack: Int
|
||||||
muxerStarted: Boolean
|
|
||||||
) {
|
) {
|
||||||
if (!muxerStarted || muxerAudioTrack < 0) return
|
// Need a separate extractor for audio since the first one was used for video
|
||||||
|
|
||||||
// Need a separate extractor for audio since the first one is used for video
|
|
||||||
val audioExtractor = MediaExtractor()
|
val audioExtractor = MediaExtractor()
|
||||||
if (uri.startsWith("content://") || uri.startsWith("file://")) {
|
if (uri.startsWith("content://") || uri.startsWith("file://")) {
|
||||||
audioExtractor.setDataSource(context, Uri.parse(uri), null)
|
audioExtractor.setDataSource(context, Uri.parse(uri), null)
|
||||||
@@ -367,162 +396,13 @@ class VideoCompressor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun processAudioReencode(
|
|
||||||
extractor: MediaExtractor,
|
|
||||||
audioTrackIndex: Int,
|
|
||||||
audioFormat: MediaFormat,
|
|
||||||
muxer: MediaMuxer,
|
|
||||||
muxerStarted: Boolean
|
|
||||||
) {
|
|
||||||
// Set up separate extractor for audio
|
|
||||||
val audioExtractor = MediaExtractor()
|
|
||||||
if (uri.startsWith("content://") || uri.startsWith("file://")) {
|
|
||||||
audioExtractor.setDataSource(context, Uri.parse(uri), null)
|
|
||||||
} else {
|
|
||||||
audioExtractor.setDataSource(uri)
|
|
||||||
}
|
|
||||||
audioExtractor.selectTrack(audioTrackIndex)
|
|
||||||
|
|
||||||
val audioMime = audioFormat.getString(MediaFormat.KEY_MIME) ?: "audio/mp4a-latm"
|
|
||||||
val sampleRate = if (audioFormat.containsKey(MediaFormat.KEY_SAMPLE_RATE)) {
|
|
||||||
audioFormat.getInteger(MediaFormat.KEY_SAMPLE_RATE)
|
|
||||||
} else {
|
|
||||||
AUDIO_SAMPLE_RATE
|
|
||||||
}
|
|
||||||
val channelCount = if (audioFormat.containsKey(MediaFormat.KEY_CHANNEL_COUNT)) {
|
|
||||||
audioFormat.getInteger(MediaFormat.KEY_CHANNEL_COUNT)
|
|
||||||
} else {
|
|
||||||
AUDIO_CHANNELS
|
|
||||||
}
|
|
||||||
|
|
||||||
// Audio decoder
|
|
||||||
val audioDecoder = MediaCodec.createDecoderByType(audioMime)
|
|
||||||
audioDecoder.configure(audioFormat, null, null, 0)
|
|
||||||
audioDecoder.start()
|
|
||||||
|
|
||||||
// Audio encoder
|
|
||||||
val audioEncoderFormat = MediaFormat.createAudioFormat(
|
|
||||||
MediaFormat.MIMETYPE_AUDIO_AAC,
|
|
||||||
sampleRate,
|
|
||||||
channelCount
|
|
||||||
).apply {
|
|
||||||
setInteger(MediaFormat.KEY_BIT_RATE, AUDIO_AAC_BITRATE)
|
|
||||||
setInteger(MediaFormat.KEY_AAC_PROFILE, MediaCodecInfo.CodecProfileLevel.AACObjectLC)
|
|
||||||
}
|
|
||||||
val audioEncoder = MediaCodec.createEncoderByType(MediaFormat.MIMETYPE_AUDIO_AAC)
|
|
||||||
audioEncoder.configure(audioEncoderFormat, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE)
|
|
||||||
audioEncoder.start()
|
|
||||||
|
|
||||||
var muxerTrack = -1
|
|
||||||
var localMuxerStarted = muxerStarted
|
|
||||||
val bufferInfo = MediaCodec.BufferInfo()
|
|
||||||
var inputDone = false
|
|
||||||
var decoderDone = false
|
|
||||||
var encoderDone = false
|
|
||||||
|
|
||||||
try {
|
|
||||||
while (!encoderDone && !isCancelled) {
|
|
||||||
// Feed decoder
|
|
||||||
if (!inputDone) {
|
|
||||||
val inputIndex = audioDecoder.dequeueInputBuffer(TIMEOUT_US)
|
|
||||||
if (inputIndex >= 0) {
|
|
||||||
val inputBuffer = audioDecoder.getInputBuffer(inputIndex) ?: continue
|
|
||||||
val sampleSize = audioExtractor.readSampleData(inputBuffer, 0)
|
|
||||||
if (sampleSize < 0) {
|
|
||||||
audioDecoder.queueInputBuffer(
|
|
||||||
inputIndex, 0, 0, 0, MediaCodec.BUFFER_FLAG_END_OF_STREAM
|
|
||||||
)
|
|
||||||
inputDone = true
|
|
||||||
} else {
|
|
||||||
audioDecoder.queueInputBuffer(
|
|
||||||
inputIndex, 0, sampleSize, audioExtractor.sampleTime, 0
|
|
||||||
)
|
|
||||||
audioExtractor.advance()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Drain decoder -> feed encoder
|
|
||||||
if (!decoderDone) {
|
|
||||||
val decoderOutputIndex = audioDecoder.dequeueOutputBuffer(bufferInfo, TIMEOUT_US)
|
|
||||||
if (decoderOutputIndex >= 0) {
|
|
||||||
val isEos = bufferInfo.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0
|
|
||||||
val decodedBuffer = audioDecoder.getOutputBuffer(decoderOutputIndex)
|
|
||||||
|
|
||||||
if (decodedBuffer != null && bufferInfo.size > 0) {
|
|
||||||
val encoderInputIndex = audioEncoder.dequeueInputBuffer(TIMEOUT_US)
|
|
||||||
if (encoderInputIndex >= 0) {
|
|
||||||
val encoderInputBuffer = audioEncoder.getInputBuffer(encoderInputIndex)
|
|
||||||
if (encoderInputBuffer != null) {
|
|
||||||
encoderInputBuffer.clear()
|
|
||||||
encoderInputBuffer.put(decodedBuffer)
|
|
||||||
audioEncoder.queueInputBuffer(
|
|
||||||
encoderInputIndex, 0, bufferInfo.size,
|
|
||||||
bufferInfo.presentationTimeUs,
|
|
||||||
if (isEos) MediaCodec.BUFFER_FLAG_END_OF_STREAM else 0
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (isEos) {
|
|
||||||
val encoderInputIndex = audioEncoder.dequeueInputBuffer(TIMEOUT_US)
|
|
||||||
if (encoderInputIndex >= 0) {
|
|
||||||
audioEncoder.queueInputBuffer(
|
|
||||||
encoderInputIndex, 0, 0, 0, MediaCodec.BUFFER_FLAG_END_OF_STREAM
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
audioDecoder.releaseOutputBuffer(decoderOutputIndex, false)
|
|
||||||
if (isEos) decoderDone = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Drain encoder -> muxer
|
|
||||||
val encoderOutputIndex = audioEncoder.dequeueOutputBuffer(bufferInfo, TIMEOUT_US)
|
|
||||||
when {
|
|
||||||
encoderOutputIndex == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED -> {
|
|
||||||
if (muxerTrack < 0) {
|
|
||||||
muxerTrack = muxer.addTrack(audioEncoder.outputFormat)
|
|
||||||
if (!localMuxerStarted) {
|
|
||||||
muxer.start()
|
|
||||||
localMuxerStarted = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
encoderOutputIndex >= 0 -> {
|
|
||||||
val outputBuffer = audioEncoder.getOutputBuffer(encoderOutputIndex)
|
|
||||||
if (outputBuffer != null &&
|
|
||||||
bufferInfo.flags and MediaCodec.BUFFER_FLAG_CODEC_CONFIG == 0 &&
|
|
||||||
bufferInfo.size > 0 &&
|
|
||||||
muxerTrack >= 0) {
|
|
||||||
muxer.writeSampleData(muxerTrack, outputBuffer, bufferInfo)
|
|
||||||
}
|
|
||||||
encoderDone = bufferInfo.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0
|
|
||||||
audioEncoder.releaseOutputBuffer(encoderOutputIndex, false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
try { audioDecoder.stop() } catch (_: Exception) {}
|
|
||||||
try { audioDecoder.release() } catch (_: Exception) {}
|
|
||||||
try { audioEncoder.stop() } catch (_: Exception) {}
|
|
||||||
try { audioEncoder.release() } catch (_: Exception) {}
|
|
||||||
audioExtractor.release()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun canPassthroughAudio(format: MediaFormat): Boolean {
|
private fun canPassthroughAudio(format: MediaFormat): Boolean {
|
||||||
val mime = format.getString(MediaFormat.KEY_MIME) ?: return false
|
val mime = format.getString(MediaFormat.KEY_MIME) ?: return false
|
||||||
if (mime != MediaFormat.MIMETYPE_AUDIO_AAC) return false
|
// Always passthrough AAC regardless of bitrate — the server re-encodes
|
||||||
|
// everything anyway, and re-encoding audio here complicates muxer startup
|
||||||
// Check bitrate if available
|
// (MediaMuxer needs all tracks added before start(), but audio encoder
|
||||||
if (format.containsKey(MediaFormat.KEY_BIT_RATE)) {
|
// output format isn't known until it produces FORMAT_CHANGED).
|
||||||
val bitrate = format.getInteger(MediaFormat.KEY_BIT_RATE)
|
return mime == MediaFormat.MIMETYPE_AUDIO_AAC
|
||||||
return bitrate <= AUDIO_AAC_BITRATE
|
|
||||||
}
|
|
||||||
|
|
||||||
// If no bitrate info, assume we should passthrough AAC
|
|
||||||
return true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun calculateOutputSize(
|
private fun calculateOutputSize(
|
||||||
|
|||||||
@@ -9,6 +9,13 @@ import {
|
|||||||
|
|
||||||
export type {CompressOptions, CompressResult, VideoMetadata}
|
export type {CompressOptions, CompressResult, VideoMetadata}
|
||||||
|
|
||||||
|
class AbortError extends Error {
|
||||||
|
name = 'AbortError'
|
||||||
|
constructor() {
|
||||||
|
super('Aborted')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let jobIdCounter = 0
|
let jobIdCounter = 0
|
||||||
|
|
||||||
export function probe(uri: string): Promise<VideoMetadata> {
|
export function probe(uri: string): Promise<VideoMetadata> {
|
||||||
@@ -27,7 +34,7 @@ export function compress(
|
|||||||
let subscription: EventSubscription | undefined
|
let subscription: EventSubscription | undefined
|
||||||
|
|
||||||
if (callbacks?.signal?.aborted) {
|
if (callbacks?.signal?.aborted) {
|
||||||
return Promise.reject(new DOMException('Aborted', 'AbortError'))
|
return Promise.reject(new AbortError())
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Promise<CompressResult>((resolve, reject) => {
|
return new Promise<CompressResult>((resolve, reject) => {
|
||||||
@@ -45,7 +52,7 @@ export function compress(
|
|||||||
const abortHandler = () => {
|
const abortHandler = () => {
|
||||||
NativeModule.cancel()
|
NativeModule.cancel()
|
||||||
subscription?.remove()
|
subscription?.remove()
|
||||||
reject(new DOMException('Aborted', 'AbortError'))
|
reject(new AbortError())
|
||||||
}
|
}
|
||||||
|
|
||||||
if (callbacks?.signal) {
|
if (callbacks?.signal) {
|
||||||
|
|||||||
@@ -357,13 +357,8 @@ class VideoCompressor {
|
|||||||
|
|
||||||
let mediaSubType = CMFormatDescriptionGetMediaSubType(formatDesc)
|
let mediaSubType = CMFormatDescriptionGetMediaSubType(formatDesc)
|
||||||
|
|
||||||
// Only passthrough AAC audio
|
// Always passthrough AAC regardless of bitrate — the server re-encodes
|
||||||
guard mediaSubType == kAudioFormatMPEG4AAC else {
|
// everything anyway, and the bitrate difference is negligible.
|
||||||
return false
|
return mediaSubType == kAudioFormatMPEG4AAC
|
||||||
}
|
|
||||||
|
|
||||||
// Check bitrate - passthrough if <= 128kbps
|
|
||||||
let estimatedDataRate = try await audioTrack.load(.estimatedDataRate)
|
|
||||||
return estimatedDataRate <= 128_000
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import {type ImagePickerAsset} from 'expo-image-picker'
|
import {type ImagePickerAsset} from 'expo-image-picker'
|
||||||
|
|
||||||
import {SUPPORTED_MIME_TYPES, type SupportedMimeTypes} from '#/lib/constants'
|
import {SUPPORTED_MIME_TYPES, type SupportedMimeTypes} from '#/lib/constants'
|
||||||
|
import {logger} from '#/logger'
|
||||||
import {compress, probe} from '../../../../modules/expo-bluesky-video-compress'
|
import {compress, probe} from '../../../../modules/expo-bluesky-video-compress'
|
||||||
import {type CompressedVideo} from './types'
|
import {type CompressedVideo} from './types'
|
||||||
|
|
||||||
// Skip compression if bitrate is at/below this threshold (bps)
|
// Skip compression if bitrate is at/below this threshold (bps)
|
||||||
const PASSTHROUGH_BITRATE = 3_300_000
|
const PASSTHROUGH_BITRATE = 5_000_000
|
||||||
// Max dimension that doesn't need downscaling
|
// Max dimension that doesn't need downscaling
|
||||||
const PASSTHROUGH_MAX_DIMENSION = 1920
|
const PASSTHROUGH_MAX_DIMENSION = 1920
|
||||||
// Max file size the server accepts (bytes)
|
// Max file size the server accepts (bytes)
|
||||||
@@ -62,25 +63,41 @@ function shouldCompress(
|
|||||||
metadata: {bitrate: number; width: number; height: number; fileSize: number},
|
metadata: {bitrate: number; width: number; height: number; fileSize: number},
|
||||||
isAcceptableFormat: boolean,
|
isAcceptableFormat: boolean,
|
||||||
): 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)
|
||||||
|
|
||||||
// Always compress unacceptable formats (e.g. MOV → MP4)
|
// Always compress unacceptable formats (e.g. MOV → MP4)
|
||||||
if (!isAcceptableFormat) {
|
if (!isAcceptableFormat) {
|
||||||
|
logger.debug('shouldCompress: yes (unsupported format)')
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Must compress if over upload limit
|
// Must compress if over upload limit
|
||||||
if (metadata.fileSize > MAX_UPLOAD_SIZE) {
|
if (metadata.fileSize > MAX_UPLOAD_SIZE) {
|
||||||
|
logger.debug(`shouldCompress: yes (file too large: ${sizeMB}MB)`)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Skip if already low bitrate, small resolution, and under upload limit
|
// Skip if already low bitrate, small resolution, and under upload limit
|
||||||
const maxDimension = Math.max(metadata.width, metadata.height)
|
|
||||||
if (
|
if (
|
||||||
metadata.bitrate <= PASSTHROUGH_BITRATE &&
|
metadata.bitrate <= PASSTHROUGH_BITRATE &&
|
||||||
maxDimension <= PASSTHROUGH_MAX_DIMENSION &&
|
maxDimension <= PASSTHROUGH_MAX_DIMENSION
|
||||||
metadata.fileSize <= MAX_UPLOAD_SIZE
|
|
||||||
) {
|
) {
|
||||||
|
logger.debug(
|
||||||
|
`shouldCompress: no (${bitrateKbps}kbps, ${maxDimension}px, ${sizeMB}MB)`,
|
||||||
|
)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (metadata.bitrate > PASSTHROUGH_BITRATE) {
|
||||||
|
logger.debug(
|
||||||
|
`shouldCompress: yes (bitrate ${bitrateKbps}kbps > ${PASSTHROUGH_BITRATE / 1000}kbps)`,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
logger.debug(
|
||||||
|
`shouldCompress: yes (dimension ${maxDimension}px > ${PASSTHROUGH_MAX_DIMENSION}px)`,
|
||||||
|
)
|
||||||
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,15 @@
|
|||||||
import {type ImagePickerAsset} from 'expo-image-picker'
|
import {type ImagePickerAsset} from 'expo-image-picker'
|
||||||
|
|
||||||
|
import * as Toast from '#/components/Toast'
|
||||||
|
import {IS_DEV} from '#/env'
|
||||||
import {type CompressedVideo} from './types'
|
import {type CompressedVideo} from './types'
|
||||||
|
|
||||||
// Toggle for A/B comparison. Set to true to use the new Expo module.
|
// Toggle for A/B comparison. Set to true to use the new Expo module.
|
||||||
const USE_NEW_COMPRESSOR = false
|
const USE_NEW_COMPRESSOR = false
|
||||||
|
|
||||||
|
// In dev, run both compressors and show a comparison toast.
|
||||||
|
const DEV_COMPARE_BOTH = true
|
||||||
|
|
||||||
export async function compressVideo(
|
export async function compressVideo(
|
||||||
file: ImagePickerAsset,
|
file: ImagePickerAsset,
|
||||||
opts?: {
|
opts?: {
|
||||||
@@ -12,6 +17,10 @@ export async function compressVideo(
|
|||||||
onProgress?: (progress: number) => void
|
onProgress?: (progress: number) => void
|
||||||
},
|
},
|
||||||
): Promise<CompressedVideo> {
|
): Promise<CompressedVideo> {
|
||||||
|
if (IS_DEV && DEV_COMPARE_BOTH) {
|
||||||
|
return compressAndCompare(file, opts)
|
||||||
|
}
|
||||||
|
|
||||||
if (USE_NEW_COMPRESSOR) {
|
if (USE_NEW_COMPRESSOR) {
|
||||||
const {compressVideo: compressVideoNew} = await import('./compress.new')
|
const {compressVideo: compressVideoNew} = await import('./compress.new')
|
||||||
return compressVideoNew(file, opts)
|
return compressVideoNew(file, opts)
|
||||||
@@ -22,3 +31,35 @@ export async function compressVideo(
|
|||||||
return compressVideoLegacy(file, opts)
|
return compressVideoLegacy(file, opts)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function compressAndCompare(
|
||||||
|
file: ImagePickerAsset,
|
||||||
|
opts?: {
|
||||||
|
signal?: AbortSignal
|
||||||
|
onProgress?: (progress: number) => void
|
||||||
|
},
|
||||||
|
): Promise<CompressedVideo> {
|
||||||
|
const {compressVideo: compressVideoLegacy} = await import('./compress.legacy')
|
||||||
|
const {compressVideo: compressVideoNew} = await import('./compress.new')
|
||||||
|
|
||||||
|
// Run legacy first (progress goes to UI)
|
||||||
|
const legacyStart = performance.now()
|
||||||
|
const legacyResult = await compressVideoLegacy(file, opts)
|
||||||
|
const legacyMs = performance.now() - legacyStart
|
||||||
|
|
||||||
|
// Run new second
|
||||||
|
const newStart = performance.now()
|
||||||
|
const newResult = await compressVideoNew(file, opts)
|
||||||
|
const newMs = performance.now() - newStart
|
||||||
|
|
||||||
|
const fmt = (ms: number, size: number) =>
|
||||||
|
`${(ms / 1000).toFixed(1)}s → ${(size / 1_000_000).toFixed(1)}MB`
|
||||||
|
|
||||||
|
Toast.show(
|
||||||
|
`legacy: ${fmt(legacyMs, legacyResult.size)}\nnew: ${fmt(newMs, newResult.size)}`,
|
||||||
|
{duration: 8000},
|
||||||
|
)
|
||||||
|
|
||||||
|
// Return whichever is selected
|
||||||
|
return USE_NEW_COMPRESSOR ? newResult : legacyResult
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user