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 @@ -47,6 +48,48 @@ abstract class MediaCodecEncoder<T : Config>(
open val bitrate: Int
get() = _bitrate

/** Rolling measured encode bitrate from MediaCodec output (bps). -1 until first 1s window. */
@Volatile
var measuredBitrateBps: Long = -1L
protected set

/** Rolling measured encode fps from MediaCodec output frames. NaN until first 1s window. */
@Volatile
var measuredOutputFps: Double = Double.NaN
protected set

private var metricsWindowBytes = 0L
private var metricsWindowFrames = 0
private var metricsWindowStartMs = 0L

private fun resetOutputMetrics() {
metricsWindowBytes = 0L
metricsWindowFrames = 0
metricsWindowStartMs = 0L
measuredBitrateBps = -1L
measuredOutputFps = Double.NaN
}

private fun recordEncodedOutput(sizeBytes: Int) {
if (sizeBytes <= 0) return
val now = System.currentTimeMillis()
if (metricsWindowStartMs == 0L) {
metricsWindowStartMs = now
metricsWindowBytes = 0L
metricsWindowFrames = 0
}
metricsWindowBytes += sizeBytes.toLong()
metricsWindowFrames++
val elapsedMs = now - metricsWindowStartMs
if (elapsedMs >= 1000L) {
measuredBitrateBps = metricsWindowBytes * 8_000L / elapsedMs
measuredOutputFps = metricsWindowFrames * 1000.0 / elapsedMs
metricsWindowBytes = 0L
metricsWindowFrames = 0
metricsWindowStartMs = now
}
}

private val encoderCallback = object : MediaCodec.Callback() {
override fun onOutputBufferAvailable(
codec: MediaCodec,
Expand All @@ -69,6 +112,7 @@ abstract class MediaCodecEncoder<T : Config>(
* Drops codec data. They are already passed in the extra buffer.
*/
if (info.flags != MediaCodec.BUFFER_FLAG_CODEC_CONFIG) {
recordEncodedOutput(info.size)
Frame(
buffer,
info.presentationTimeUs, // pts
Expand Down Expand Up @@ -180,6 +224,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}")
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Parameters set before configure

Medium Severity

codec.setParameters runs before codec.configure, while the codec is still uninitialized. MediaCodec only accepts parameters in the executing state, so this always fails into the catch block. The comment mentions operating rate, but the code sets PARAMETER_KEY_VIDEO_BITRATE on both audio and video encoders.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit e7a4517. Configure here.


try {
codec.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE)
} catch (e: Exception) {
Expand Down Expand Up @@ -213,6 +269,7 @@ abstract class MediaCodecEncoder<T : Config>(
synchronized(lock) {
isOnError = false
isStopped = false
resetOutputMetrics()
mediaCodec?.start() ?: throw IllegalStateException("Can't start without configuration")
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,8 @@ class VideoMediaCodecEncoder(

override fun extendMediaFormat(config: Config, format: MediaFormat) {
val videoConfig = config as VideoConfig
codecSurface?.captureResolution = videoConfig.captureResolution
codecSurface?.setTargetFps(videoConfig.fps)
orientationProvider?.let {
it.getOrientedSize(videoConfig.resolution).apply {
// Override previous format
Expand All @@ -123,17 +125,102 @@ class VideoMediaCodecEncoder(
val inputSurface: Surface?
get() = codecSurface?.inputSurface

/**
* Measured encode fps from MediaCodec output when available; otherwise surface throttle rate.
*/
val measuredFps: Double
get() {
val encodeFps = measuredOutputFps
if (encodeFps.isFinite() && encodeFps > 0) return encodeFps
return codecSurface?.measuredFps ?: Double.NaN
}

class CodecSurface(
private val orientationProvider: ISourceOrientationProvider?
) :
SurfaceTexture.OnFrameAvailableListener, ISourceOrientationListener {
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)

// Pace encode to VideoConfig.fps with a next-deadline schedule (not
// min-interval-since-last-accept). Min-interval phase-locks against a
// higher camera cadence when the target is not a divisor — e.g. 30fps
// capture targeting 20fps locks to every other frame (~15fps).
private var nextFrameDueMs = 0L
@Volatile
private var targetFrameIntervalMs = 66L // default ~15fps until setTargetFps()
private var acceptedFrameCount = 0
private var measuredWindowStartMs = 0L
@Volatile
var measuredFps: Double = Double.NaN
private set

fun setTargetFps(fps: Int) {
val clamped = fps.coerceAtLeast(1)
targetFrameIntervalMs = (1000L / clamped).coerceAtLeast(1L)
nextFrameDueMs = 0L
}

/**
* Returns true when this camera frame should be encoded. Advances the
* deadline by one target interval on accept so average rate matches
* [targetFrameIntervalMs] even when capture fps is not a multiple of
* the encode fps (30→20 yields ~20, not ~15).
*/
private fun shouldAcceptFrame(nowMs: Long): Boolean {
if (nextFrameDueMs == 0L) {
nextFrameDueMs = nowMs + targetFrameIntervalMs
return true
}
if (nowMs < nextFrameDueMs) {
return false
}
nextFrameDueMs += targetFrameIntervalMs
// If we fell more than one interval behind, resync so we don't
// accept a burst of queued frames back-to-back.
if (nextFrameDueMs <= nowMs) {
nextFrameDueMs = nowMs + targetFrameIntervalMs
}
return true
}

private fun recordAcceptedFrame(nowMs: Long) {
if (measuredWindowStartMs == 0L) {
measuredWindowStartMs = nowMs
acceptedFrameCount = 0
}
acceptedFrameCount++
val elapsedMs = nowMs - measuredWindowStartMs
if (elapsedMs >= 1000L) {
measuredFps = acceptedFrameCount * 1000.0 / elapsedMs
acceptedFrameCount = 0
measuredWindowStartMs = nowMs
}
}

/** Consume a dropped camera frame so SurfaceTexture buffers don't stall. */
private fun drainDroppedFrame(surfaceTexture: SurfaceTexture) {
executor.execute {
synchronized(this) {
eglSurface?.let {
it.makeCurrent()
surfaceTexture.updateTexImage()
surfaceTexture.releaseTexImage()
}
}
}
}

private var _inputSurface: Surface? = null
val inputSurface: Surface?
Expand All @@ -144,6 +231,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 +265,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 +321,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 @@ -249,6 +350,16 @@ class VideoMediaCodecEncoder(
return
}

// Pace to VideoConfig.fps once the camera has produced a real frame.
val currentTimeMs = System.currentTimeMillis()
if (surfaceTexture.timestamp != 0L) {
if (!shouldAcceptFrame(currentTimeMs)) {
drainDroppedFrame(surfaceTexture)
return
}
recordAcceptedFrame(currentTimeMs)
}
Comment thread
cursor[bot] marked this conversation as resolved.

executor.execute {
synchronized(this) {
eglSurface?.let {
Expand All @@ -271,6 +382,10 @@ class VideoMediaCodecEncoder(
ensureGlContext(eglSurface) {
surfaceTexture?.updateTexImage()
}
nextFrameDueMs = 0L
acceptedFrameCount = 0
measuredWindowStartMs = 0L
measuredFps = Double.NaN
isRunning = true
}

Expand Down
Loading