Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import io.github.thibaultbee.streampack.internal.utils.extensions.landscapize
import io.github.thibaultbee.streampack.internal.utils.extensions.portraitize
import io.github.thibaultbee.streampack.streamers.bases.BaseStreamer
import java.security.InvalidParameterException
import kotlin.jvm.JvmOverloads
import kotlin.math.roundToInt

/**
Expand All @@ -47,7 +48,7 @@ import kotlin.math.roundToInt
*
* @see [BaseStreamer.configure]
*/
class VideoConfig(
class VideoConfig @JvmOverloads constructor(
/**
* Video encoder mime type.
* Only [MediaFormat.MIMETYPE_VIDEO_AVC], [MediaFormat.MIMETYPE_VIDEO_HEVC],
Expand Down Expand Up @@ -89,7 +90,13 @@ class VideoConfig(
* A value of 0 means that each frame is an I-frame.
* On device with API < 25, this value will be rounded to an integer. So don't expect a precise value and any value < 0.5 will be considered as 0.
*/
val gopDuration: Float = 1f // 1s between I frames
val gopDuration: Float = 1f, // 1s between I frames
/**
* Optional camera / SurfaceTexture buffer size. When set (and larger than [resolution] in
* at least one dimension), frames are center-cropped in the GL path to [resolution] before
* encoding. Encoder [MediaFormat] always uses [resolution].
*/
val captureResolution: Size? = null
) : Config(mimeType, startBitrate, profile) {
init {
require(mimeType.isVideo) { "MimeType must be video" }
Expand Down Expand Up @@ -126,15 +133,17 @@ class VideoConfig(
* This is a best effort as few camera can not generate a fixed framerate.
* For live streaming, I-frame interval should be really low. For recording, I-frame interval should be higher.
*/
gopDuration: Float = 1f // 1s between I frames
gopDuration: Float = 1f, // 1s between I frames
captureResolution: Size? = null
) : this(
mimeType,
startBitrate,
resolution,
fps,
profileLevel.profile,
profileLevel.level,
gopDuration
gopDuration,
captureResolution
)

/**
Expand Down Expand Up @@ -277,6 +286,6 @@ class VideoConfig(
}

override fun toString() =
"VideoConfig(mimeType='$mimeType', startBitrate=$startBitrate, resolution=$resolution, fps=$fps, profile=$profile, level=$level)"
"VideoConfig(mimeType='$mimeType', startBitrate=$startBitrate, resolution=$resolution, captureResolution=$captureResolution, fps=$fps, profile=$profile, level=$level)"
}

Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ package io.github.thibaultbee.streampack.internal.encoders
import android.media.MediaCodec
import android.media.MediaFormat
import android.os.Build
import android.os.Bundle
import android.os.Handler
import android.os.HandlerThread
import io.github.thibaultbee.streampack.data.Config
Expand Down Expand Up @@ -180,6 +181,18 @@ abstract class MediaCodecEncoder<T : Config>(
codec.setCallback(encoderCallback)
}

// Power-efficient encoding parameters - safer version
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
try {
// Set operating rate to normal (not low-latency) - more power efficient
val params = Bundle()
params.putInt(MediaCodec.PARAMETER_KEY_VIDEO_BITRATE, _bitrate)
codec.setParameters(params)
} catch (e: Exception) {
Logger.d(TAG, "Could not set encoder parameters: ${e.message}")
}
}

try {
codec.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE)
} catch (e: Exception) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ class VideoMediaCodecEncoder(

override fun extendMediaFormat(config: Config, format: MediaFormat) {
val videoConfig = config as VideoConfig
codecSurface?.captureResolution = videoConfig.captureResolution
orientationProvider?.let {
it.getOrientedSize(videoConfig.resolution).apply {
// Override previous format
Expand Down Expand Up @@ -130,10 +131,20 @@ class VideoMediaCodecEncoder(
private var eglSurface: EglWindowSurface? = null
private var fullFrameRect: FullFrameRect? = null
private var textureId = -1
private val executor = Executors.newSingleThreadExecutor()
// Single thread with minimal priority executor for power savings
private val executor = Executors.newSingleThreadExecutor { r ->
Thread(r).apply {
priority = Thread.MIN_PRIORITY
name = "encoder-power-save-thread"
}
}
private var isRunning = false
private var surfaceTexture: SurfaceTexture? = null
private val stMatrix = FloatArray(16)

// Power optimization: batch frame processing to reduce wake-ups - strict 24fps cap
private var lastFrameTimeMs = 0L
private val minFrameIntervalMs = 41L // ~24fps max to match video encoding settings

private var _inputSurface: Surface? = null
val inputSurface: Surface?
Expand All @@ -144,6 +155,12 @@ class VideoMediaCodecEncoder(
*/
var useHighBitDepth = false

/**
* When non-null, [SurfaceTexture.setDefaultBufferSize] uses this size and
* [FullFrameRect] center-crops to the encoder viewport.
*/
var captureResolution: Size? = null

var outputSurface: Surface? = null
set(value) {
/**
Expand Down Expand Up @@ -172,20 +189,24 @@ class VideoMediaCodecEncoder(
eglSurface = ensureGlContext(EglWindowSurface(surface, useHighBitDepth)) {
val width = it.getWidth()
val height = it.getHeight()
val size =
val encoderSize =
orientationProvider?.getOrientedSize(Size(width, height)) ?: Size(width, height)
val captureOriented = captureResolution?.let { cr ->
orientationProvider?.getOrientedSize(cr) ?: cr
}
val defaultBufferSize = captureOriented
?: (orientationProvider?.getDefaultBufferSize(encoderSize) ?: Size(width, height))
val orientation = orientationProvider?.orientation ?: 0
fullFrameRect = FullFrameRect(Texture2DProgram()).apply {
textureId = createTextureObject()
setMVPMatrixAndViewPort(
setMVPMatrixViewPortAndCrop(
orientation.toFloat(),
size,
encoderSize,
captureOriented ?: encoderSize,
orientationProvider?.mirroredVertically ?: false
)
}

val defaultBufferSize =
orientationProvider?.getDefaultBufferSize(size) ?: Size(width, height)
surfaceTexture = attachOrBuildSurfaceTexture(surfaceTexture).apply {
setDefaultBufferSize(defaultBufferSize.width, defaultBufferSize.height)
setOnFrameAvailableListener(this@CodecSurface)
Expand Down Expand Up @@ -224,12 +245,16 @@ class VideoMediaCodecEncoder(
val width = it.getWidth()
val height = it.getHeight()

fullFrameRect?.setMVPMatrixAndViewPort(
val encoderSize =
orientationProvider?.getOrientedSize(Size(width, height))
?: Size(width, height)
val captureOriented = captureResolution?.let { cr ->
orientationProvider?.getOrientedSize(cr) ?: cr
}
fullFrameRect?.setMVPMatrixViewPortAndCrop(
(orientationProvider?.orientation ?: 0).toFloat(),
orientationProvider?.getOrientedSize(Size(width, height)) ?: Size(
width,
height
),
encoderSize,
captureOriented ?: encoderSize,
orientationProvider?.mirroredVertically ?: false
)

Expand All @@ -248,6 +273,17 @@ class VideoMediaCodecEncoder(
if (!isRunning) {
return
}

// Aggressive frame throttling strictly capped at 24fps
val currentTimeMs = System.currentTimeMillis()
// Only throttle if we're already processing frames (not on startup)
if (surfaceTexture != null && !surfaceTexture!!.timestamp.equals(0L)) {
if (currentTimeMs - lastFrameTimeMs < minFrameIntervalMs) {
// Skip frames to strictly maintain 24fps - saving significant CPU
return
}
lastFrameTimeMs = currentTimeMs
}

executor.execute {
synchronized(this) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ import android.util.Size
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.nio.FloatBuffer
import kotlin.math.abs
import kotlin.math.max
import kotlin.math.min
import kotlin.math.roundToInt


/**
Expand All @@ -32,6 +36,7 @@ import java.nio.FloatBuffer
*/
class FullFrameRect(var program: Texture2DProgram) {
private val mvpMatrix = FloatArray(16)
private var texCoordBuffer: FloatBuffer = duplicateTexCoords(FULL_RECTANGLE_TEX_COORDS)

companion object {
/**
Expand Down Expand Up @@ -63,11 +68,58 @@ class FullFrameRect(var program: Texture2DProgram) {
// Allocate a direct ByteBuffer, using 4 bytes per float, and copy coords into it.
val bb: ByteBuffer = ByteBuffer.allocateDirect(coords.size * Float.SIZE_BYTES)
bb.order(ByteOrder.nativeOrder())
val fb: FloatBuffer = bb.asFloatBuffer()
val fb = bb.asFloatBuffer()
fb.put(coords)
fb.position(0)
return fb
}

private fun duplicateTexCoords(coords: FloatArray): FloatBuffer {
val bb: ByteBuffer = ByteBuffer.allocateDirect(coords.size * Float.SIZE_BYTES)
bb.order(ByteOrder.nativeOrder())
val fb = bb.asFloatBuffer()
fb.put(coords)
fb.position(0)
return fb
}

/** Center-crop rectangle in pixel space (top-left origin) matching target aspect. */
private fun centerCropRect(
captureWidth: Int,
captureHeight: Int,
targetWidth: Int,
targetHeight: Int
): FloatArray {
if (captureWidth <= 0 || captureHeight <= 0 || targetWidth <= 0 || targetHeight <= 0) {
return floatArrayOf(0f, 0f, 1f, 1f)
}
val sourceAspect = captureWidth / captureHeight.toFloat()
val targetAspect = targetWidth / targetHeight.toFloat()
var cropW = captureWidth
var cropH = captureHeight
if (abs(sourceAspect - targetAspect) > 0.0001f) {
if (sourceAspect > targetAspect) {
cropW = (captureHeight * targetAspect).roundToInt()
} else {
cropH = (captureWidth / targetAspect).roundToInt()
}
}
cropW = max(1, min(captureWidth, cropW))
cropH = max(1, min(captureHeight, cropH))
val cropX = max(0, (captureWidth - cropW) / 2)
val cropY = max(0, (captureHeight - cropH) / 2)
// GL texture coords with v=0 at bottom (SurfaceTexture / OES convention)
val u0 = cropX / captureWidth.toFloat()
val u1 = (cropX + cropW) / captureWidth.toFloat()
val v0 = (captureHeight - (cropY + cropH)) / captureHeight.toFloat()
val v1 = (captureHeight - cropY) / captureHeight.toFloat()
return floatArrayOf(
u0, v0,
u1, v0,
u0, v1,
u1, v1
)
}
}

/**
Expand Down Expand Up @@ -104,13 +156,57 @@ class FullFrameRect(var program: Texture2DProgram) {
}

fun setMVPMatrixAndViewPort(rotation: Float, resolution: Size, mirroredVertically: Boolean) {
setMVPMatrixViewPortAndCrop(rotation, resolution, resolution, mirroredVertically)
}

/**
* Sets MVP + viewport to [viewport] size, and texture coordinates to center-crop [capture]
* to match the aspect ratio of [viewport] after accounting for [rotation] (swap width/height
* for 90° / 270° when comparing aspects, matching how the MVP rotates the drawn quad).
*/
fun setMVPMatrixViewPortAndCrop(
rotation: Float,
viewport: Size,
capture: Size,
mirroredVertically: Boolean
) {
Matrix.setIdentityM(mvpMatrix, 0)
Matrix.scaleM(mvpMatrix, 0, if (mirroredVertically) -1f else 1f, 1f, 0f)
Matrix.rotateM(
mvpMatrix, 0,
rotation, 0f, 0f, -1f
)
GLES20.glViewport(0, 0, resolution.width, resolution.height)
GLES20.glViewport(0, 0, viewport.width, viewport.height)

val rotNorm = ((rotation.toInt() % 360) + 360) % 360
val aspectW: Int
val aspectH: Int
when (rotNorm) {
90, 270 -> {
aspectW = viewport.height
aspectH = viewport.width
}
else -> {
aspectW = viewport.width
aspectH = viewport.height
}
}

if (capture.width == viewport.width && capture.height == viewport.height) {
texCoordBuffer = duplicateTexCoords(FULL_RECTANGLE_TEX_COORDS)
return
}

if (aspectW == capture.width && aspectH == capture.height) {
texCoordBuffer = duplicateTexCoords(FULL_RECTANGLE_TEX_COORDS)
return
}

val coords = centerCropRect(
capture.width, capture.height,
aspectW, aspectH
)
texCoordBuffer = duplicateTexCoords(coords)
}

/**
Expand All @@ -121,7 +217,7 @@ class FullFrameRect(var program: Texture2DProgram) {
program.draw(
mvpMatrix, FULL_RECTANGLE_BUF, 0,
4, 2, 2 * Float.SIZE_BYTES,
texMatrix, FULL_RECTANGLE_TEX_BUF, textureId, 2 * Float.SIZE_BYTES
texMatrix, texCoordBuffer, textureId, 2 * Float.SIZE_BYTES
)
}
}
}
Loading