Select camera resolution and frame rate in Android calls
This commit is contained in:
1 parent
27cbf2ba44
commit
d8d9f7c4a4
3 files changed
+70
-43
No files matched your search
@@ -0,0 +1,15 @@
|
||||
package com.utun.chat.data
|
||||
|
||||
/** Отбор raw кадров по фазе, как в desktop: допуск дрожания PTS, без burst после паузы. */
|
||||
internal class CallVideoCadence(private val fps: Int) {
|
||||
private var lastPts = -1L
|
||||
private var next = -1L
|
||||
fun accept(pts: Long): Boolean {
|
||||
if (fps !in 1..60 || pts < 0 || pts <= lastPts || pts > (Long.MAX_VALUE - 1_250_000) / fps) return false
|
||||
lastPts = pts
|
||||
val scaled = pts * fps
|
||||
if (next >= 0 && scaled < next - 250_000) return false
|
||||
next = if (next < 0 || scaled - next > 250_000) scaled + 1_000_000 else next + 1_000_000
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -22,18 +22,20 @@ import kotlinx.coroutines.flow.StateFlow
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.ByteOrder
|
||||
|
||||
data class CallVideoCamera(val id: String, val name: String, val sizes: List<Size>, val sensor: Int, val front: Boolean)
|
||||
data class CallVideoMode(val width: Int, val height: Int, val fps: Int)
|
||||
data class CallVideoCamera(val id: String, val name: String, val modes: List<CallVideoMode>, val sensor: Int, val front: Boolean)
|
||||
data class CallVideoSettings(val enabled: Boolean = false, val camera: String = "", val width: Int = 640,
|
||||
val height: Int = 480, val codec: Int = 0)
|
||||
val height: Int = 480, val codec: Int = 0, val fps: Int = 30)
|
||||
data class CallVideoState(val ready: Boolean = false, val settings: CallVideoSettings = CallVideoSettings(),
|
||||
val cameras: List<CallVideoCamera> = emptyList(), val codecs: List<Int> = emptyList(), val bitrate: Int = 0,
|
||||
val range: CallVideoBitrateRange = CallVideoBitrateRange(), val status: String = "", val transmitting: Boolean = false,
|
||||
val codecSizes: Map<Int, List<Size>> = emptyMap(), val notice: Int? = null,
|
||||
val codecModes: Map<Int, List<CallVideoMode>> = emptyMap(), val notice: Int? = null,
|
||||
val activeCodec: Int = 0) { // Фактически отправленный codec; 0 до первого принятого TX кадра.
|
||||
fun sizes(camera: String = settings.camera, codec: Int = settings.codec): List<Size> {
|
||||
val allowed = if (codec == 0) codecSizes.values.flatten() else codecSizes[codec].orEmpty()
|
||||
return cameras.firstOrNull { it.id == camera }?.sizes.orEmpty().filter { it in allowed }
|
||||
fun modes(camera: String = settings.camera, codec: Int = settings.codec): List<CallVideoMode> {
|
||||
val allowed = if (codec == 0) codecModes.values.flatten() else codecModes[codec].orEmpty()
|
||||
return cameras.firstOrNull { it.id == camera }?.modes.orEmpty().filter { it in allowed }
|
||||
}
|
||||
fun sizes(camera: String = settings.camera, codec: Int = settings.codec) = modes(camera, codec).map { Size(it.width, it.height) }.distinct()
|
||||
}
|
||||
|
||||
/** Сервис владеет capture и RX; экран только выбирает режим и передаёт Surface. */
|
||||
@@ -143,14 +145,17 @@ class CallVideoEngine(private val context: Context, private val id: Long) {
|
||||
return@mapNotNull null
|
||||
}
|
||||
val rates = c.get(CameraCharacteristics.CONTROL_AE_AVAILABLE_TARGET_FPS_RANGES).orEmpty()
|
||||
val sizes = map.getOutputSizes(ImageFormat.YUV_420_888).orEmpty().filter {
|
||||
(it == Size(640, 480) || it == Size(1280, 720) || it == Size(1920, 1080)) &&
|
||||
rates.any { range -> range.upper == 30 } &&
|
||||
map.getOutputMinFrameDuration(ImageFormat.YUV_420_888, it) <= 33_366_700L
|
||||
}
|
||||
val supported = sizes.filter { size -> encoders.values.any { size in it.sizes } }
|
||||
val modes = map.getOutputSizes(ImageFormat.YUV_420_888).orEmpty()
|
||||
.filter { it == Size(640, 480) || it == Size(1280, 720) || it == Size(1920, 1080) }.flatMap { size ->
|
||||
listOf(30, 50, 60).filter { fps -> rates.any { it.upper >= fps } &&
|
||||
map.getOutputMinFrameDuration(ImageFormat.YUV_420_888, size) <= 1_000_000_000L / fps + 1000
|
||||
}.map { CallVideoMode(size.width, size.height, it) }
|
||||
}
|
||||
val supported = modes.filter { mode -> encoders.values.any { encoder -> encoder.modes.any {
|
||||
it.size == Size(mode.width, mode.height) && it.fps == mode.fps
|
||||
} } }
|
||||
LogManager.addLog(if (supported.isEmpty()) "WARN" else "INFO", "call",
|
||||
"video camera id=${lens.cameraId} 30fpsYuv=$sizes verified=$supported")
|
||||
"video camera id=${lens.cameraId} aeRanges=${rates.toList()} yuv=$modes verified=$supported")
|
||||
if (supported.isEmpty()) null else CallVideoCamera(lens.cameraId, lens.label, supported,
|
||||
c.get(CameraCharacteristics.SENSOR_ORIENTATION) ?: 0,
|
||||
c.get(CameraCharacteristics.LENS_FACING) == CameraCharacteristics.LENS_FACING_FRONT)
|
||||
@@ -161,7 +166,9 @@ class CallVideoEngine(private val context: Context, private val id: Long) {
|
||||
val level = if (direction == 0) encoders[codec]?.level else decoders[codec]?.level
|
||||
if (profile != null && level != null) {
|
||||
val size = if (direction == 0) encoders.getValue(codec).sizes.maxBy { it.width * it.height } else decoders.getValue(codec).size
|
||||
bytes.putShort(size.width.toShort()).putShort(size.height.toShort()).putShort(30).putInt(size.width * size.height * 30)
|
||||
val fps = if (direction == 0) encoders.getValue(codec).fps else decoders.getValue(codec).fps
|
||||
val pps = if (direction == 0) encoders.getValue(codec).pixelsPerSecond else decoders.getValue(codec).pixelsPerSecond
|
||||
bytes.putShort(size.width.toShort()).putShort(size.height.toShort()).putShort(fps.toShort()).putInt(pps)
|
||||
.putInt(4_000_000).put(level.toByte()).put(profile.toByte())
|
||||
}
|
||||
else bytes.position(bytes.position() + 16)
|
||||
@@ -169,11 +176,12 @@ class CallVideoEngine(private val context: Context, private val id: Long) {
|
||||
caps = checkNotNull(CallVideoNative.rotatableCaps(caps)) { "Invalid verified video capabilities" }
|
||||
check(CallVideoNative.open(id, caps)) { "Video port unavailable" }
|
||||
val firstCamera = cameras.firstOrNull()
|
||||
val firstSize = firstCamera?.sizes?.firstOrNull { it.width == wanted.width && it.height == wanted.height }
|
||||
?: firstCamera?.sizes?.minByOrNull { it.width * it.height }
|
||||
wanted = wanted.copy(camera = firstCamera?.id.orEmpty(), width = firstSize?.width ?: 640, height = firstSize?.height ?: 480)
|
||||
val firstMode = firstCamera?.modes?.firstOrNull { it.width == wanted.width && it.height == wanted.height && it.fps == wanted.fps }
|
||||
?: firstCamera?.modes?.minWithOrNull(compareBy<CallVideoMode> { it.width * it.height }.thenBy { it.fps })
|
||||
wanted = wanted.copy(camera = firstCamera?.id.orEmpty(), width = firstMode?.width ?: 640,
|
||||
height = firstMode?.height ?: 480, fps = firstMode?.fps ?: 30)
|
||||
mutable.value = mutable.value.copy(ready = true, cameras = cameras, codecs = encoders.keys.toList(), settings = wanted,
|
||||
codecSizes = encoders.mapValues { it.value.sizes })
|
||||
codecModes = encoders.mapValues { entry -> entry.value.modes.map { CallVideoMode(it.size.width, it.size.height, it.fps) } })
|
||||
report(if (encoders.isEmpty()) "Receive-only video" else "Video ready")
|
||||
tick()
|
||||
} catch (e: Exception) { fail("Video setup: ${e.message}") }
|
||||
@@ -189,8 +197,8 @@ class CallVideoEngine(private val context: Context, private val id: Long) {
|
||||
if (stopped) { LogManager.addLog("WARN", "call", "video selection after stop id=$id settings=$settings"); return@post }
|
||||
val selected = if (settings.camera.isEmpty()) wanted.copy(enabled = settings.enabled) else settings
|
||||
val current = mutable.value
|
||||
if (selected.enabled && (!current.ready || current.sizes(selected.camera, selected.codec).none {
|
||||
it.width == selected.width && it.height == selected.height
|
||||
if (selected.enabled && (!current.ready || current.modes(selected.camera, selected.codec).none {
|
||||
it.width == selected.width && it.height == selected.height && it.fps == selected.fps
|
||||
})) {
|
||||
LogManager.addLog("WARN", "call", "video selection unavailable id=$id ready=${current.ready} " +
|
||||
"requested=$settings resolved=$selected status=${current.status}")
|
||||
@@ -270,8 +278,8 @@ class CallVideoEngine(private val context: Context, private val id: Long) {
|
||||
val height = if (turn % 180 == 0) settings.height else settings.width
|
||||
for (codec in if (settings.codec == 0) listOf(2, 1) else listOf(settings.codec)) {
|
||||
if (codec in excluded) continue
|
||||
val implementation = encoders[codec]?.modes?.firstOrNull { it.size == Size(settings.width, settings.height) } ?: continue
|
||||
val desired = CallVideoFormat(++generation, width, height, codec = codec, profile = implementation.profile)
|
||||
val implementation = encoders[codec]?.modes?.firstOrNull { it.size == Size(settings.width, settings.height) && it.fps == settings.fps } ?: continue
|
||||
val desired = CallVideoFormat(++generation, width, height, fps = settings.fps, codec = codec, profile = implementation.profile)
|
||||
val selected = CallVideoNative.choose(caps, peer, desired.array()) ?: continue
|
||||
var candidate: CallVideoCodec? = null
|
||||
try {
|
||||
@@ -309,7 +317,7 @@ class CallVideoEngine(private val context: Context, private val id: Long) {
|
||||
check(CallVideoNative.format(id, candidate.format.array(), candidate.bitrate)) { "Video format rejected" }
|
||||
} catch (e: Exception) { candidate.close(); throw e }
|
||||
val sourceChanged = previousSettings == null || settings.camera != previousSettings.camera
|
||||
|| settings.width != previousSettings.width || settings.height != previousSettings.height
|
||||
|| settings.width != previousSettings.width || settings.height != previousSettings.height || settings.fps != previousSettings.fps
|
||||
if (sourceChanged) closeCamera()
|
||||
encoder = candidate; format = candidate.format; active = settings; wanted = settings
|
||||
pendingRequest = 0; confirmingRequest = 0; request = 0; lastKeyRequest = -1; needKey = true
|
||||
@@ -321,7 +329,7 @@ class CallVideoEngine(private val context: Context, private val id: Long) {
|
||||
"cameraRestart=$sourceChanged preview=${preview.state.value.format}")
|
||||
if (restored) LogManager.addLog("WARN", "call", "video mode unavailable id=$id requested=${targets.first()} restored=$settings")
|
||||
report(if (restored) "Selected mode unavailable; previous settings restored" else
|
||||
"${if (candidate.format.codec == 2) "H.265" else "H.264"} · ${candidate.format.width}×${candidate.format.height}")
|
||||
"${if (candidate.format.codec == 2) "H.265" else "H.264"} · ${candidate.format.width}×${candidate.format.height} · ${candidate.format.fps} fps")
|
||||
if (camera == null && !closing && !opening) openCamera()
|
||||
return
|
||||
}
|
||||
@@ -334,6 +342,7 @@ class CallVideoEngine(private val context: Context, private val id: Long) {
|
||||
val c = manager.getCameraCharacteristics(settings.camera)
|
||||
realtime = c.get(CameraCharacteristics.SENSOR_INFO_TIMESTAMP_SOURCE) == CameraCharacteristics.SENSOR_INFO_TIMESTAMP_SOURCE_REALTIME
|
||||
sensorOffset = null
|
||||
val cadence = CallVideoCadence(settings.fps)
|
||||
val nextReader = ImageReader.newInstance(settings.width, settings.height, ImageFormat.YUV_420_888, 3); reader = nextReader
|
||||
nextReader.setOnImageAvailableListener({ source ->
|
||||
val image = try { source.acquireLatestImage() } catch (e: IllegalStateException) {
|
||||
@@ -347,8 +356,8 @@ class CallVideoEngine(private val context: Context, private val id: Long) {
|
||||
sensorOffset = it; LogManager.addLog("WARN", "call", "camera timestamp calibrated from arrival id=$id offset=$it")
|
||||
}
|
||||
val pts = (image.timestamp + offset) / 1000
|
||||
// Camera2 уже ограничена 30 fps. Порог 33333 мкс отбрасывал каждый второй кадр при дрожании часов.
|
||||
if (pts <= lastPts) return@setOnImageAvailableListener
|
||||
// Отбираем raw до копирования/encode; частота передачи не зависит от долга приёмника.
|
||||
if (pts <= lastPts || !cadence.accept(pts)) return@setOnImageAvailableListener
|
||||
drain()
|
||||
// До восьми принятых входов/готовых выходов: backpressure действует до кодирования.
|
||||
if (switching || !canEncode || pendingFrames.isNotEmpty() || (encoder?.pendingFrames ?: 0) >= 8)
|
||||
@@ -464,13 +473,14 @@ class CallVideoEngine(private val context: Context, private val id: Long) {
|
||||
session = s
|
||||
try {
|
||||
val range = c.get(CameraCharacteristics.CONTROL_AE_AVAILABLE_TARGET_FPS_RANGES).orEmpty()
|
||||
.filter { it.upper == 30 }.maxByOrNull { it.lower } ?: error("Camera lacks 30 fps")
|
||||
.filter { it.upper >= settings.fps }.minWithOrNull(compareBy<android.util.Range<Int>> { it.upper }.thenByDescending { it.lower })
|
||||
?: error("Camera lacks ${settings.fps} fps")
|
||||
val request = device.createCaptureRequest(CameraDevice.TEMPLATE_RECORD).apply {
|
||||
targets.forEach(::addTarget); set(CaptureRequest.CONTROL_AE_TARGET_FPS_RANGE, range)
|
||||
}.build()
|
||||
s.setRepeatingRequest(request, null, handler)
|
||||
if (output != null) preview.update(previewFormat, active = true)
|
||||
LogManager.addLog("INFO", "call", "camera capture id=$id session=$serial range=$range preview=$previewFormat")
|
||||
LogManager.addLog("INFO", "call", "camera capture id=$id session=$serial range=$range outputFps=${settings.fps} preview=$previewFormat")
|
||||
} catch (e: Exception) { s.close(); rejected("Camera request: ${e.message}") }
|
||||
}
|
||||
override fun onConfigureFailed(s: CameraCaptureSession) { s.close(); complete(); rejected("Camera session configuration failed") }
|
||||
|
||||
+17
-15
@@ -65,10 +65,11 @@ internal fun CallVideoToolbar(video: CallVideoEngine, onInteraction: (Boolean) -
|
||||
state.cameras.filter { state.sizes(it.id).isNotEmpty() }.forEach { option ->
|
||||
CaptureOptionItem(option.name, option.id == state.settings.camera, onClick = {
|
||||
menu = null
|
||||
val sizes = state.sizes(option.id)
|
||||
val size = sizes.firstOrNull { it.width == state.settings.width && it.height == state.settings.height } ?: sizes.first()
|
||||
LogManager.addLog("INFO", "call", "video toolbar camera=${option.id} size=$size")
|
||||
CallController.setVideo(state.settings.copy(camera = option.id, width = size.width, height = size.height))
|
||||
val modes = state.modes(option.id)
|
||||
val mode = modes.firstOrNull { it.width == state.settings.width && it.height == state.settings.height && it.fps == state.settings.fps }
|
||||
?: modes.firstOrNull { it.fps == state.settings.fps } ?: modes.first()
|
||||
LogManager.addLog("INFO", "call", "video toolbar camera=${option.id} mode=$mode")
|
||||
CallController.setVideo(state.settings.copy(camera = option.id, width = mode.width, height = mode.height, fps = mode.fps))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -84,12 +85,13 @@ internal fun CallVideoToolbar(video: CallVideoEngine, onInteraction: (Boolean) -
|
||||
DropdownMenu(menu == CallVideoOption.CODEC, onDismissRequest = { menu = null }) {
|
||||
for (codec in listOf(0) + state.codecs.sortedDescending()) {
|
||||
val label = when (codec) { 0 -> stringResource(R.string.call_video_auto); 2 -> "H.265"; else -> "H.264" }
|
||||
val sizes = state.sizes(codec = codec)
|
||||
if (sizes.isNotEmpty()) CaptureOptionItem(label, codec == state.settings.codec, onClick = {
|
||||
val modes = state.modes(codec = codec)
|
||||
if (modes.isNotEmpty()) CaptureOptionItem(label, codec == state.settings.codec, onClick = {
|
||||
menu = null
|
||||
val size = sizes.firstOrNull { it.width == state.settings.width && it.height == state.settings.height } ?: sizes.first()
|
||||
LogManager.addLog("INFO", "call", "video toolbar codec=$codec size=$size")
|
||||
CallController.setVideo(state.settings.copy(codec = codec, width = size.width, height = size.height))
|
||||
val mode = modes.firstOrNull { it.width == state.settings.width && it.height == state.settings.height && it.fps == state.settings.fps }
|
||||
?: modes.firstOrNull { it.fps == state.settings.fps } ?: modes.first()
|
||||
LogManager.addLog("INFO", "call", "video toolbar codec=$codec mode=$mode")
|
||||
CallController.setVideo(state.settings.copy(codec = codec, width = mode.width, height = mode.height, fps = mode.fps))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -111,16 +113,16 @@ internal fun CallVideoToolbar(video: CallVideoEngine, onInteraction: (Boolean) -
|
||||
}
|
||||
}
|
||||
Box {
|
||||
CaptureControlButton("$resolutionLabel: ${state.settings.width}×${state.settings.height}",
|
||||
CaptureControlButton("$resolutionLabel: ${state.settings.width}×${state.settings.height} · ${state.settings.fps} fps",
|
||||
enabled = state.ready && state.sizes().isNotEmpty(), onClick = { menu = CallVideoOption.RESOLUTION }) {
|
||||
CaptureValueIcon(CaptureIconShape.SCREEN, "${minOf(state.settings.width, state.settings.height)}p", detail = "30fps")
|
||||
CaptureValueIcon(CaptureIconShape.SCREEN, "${minOf(state.settings.width, state.settings.height)}p", detail = "${state.settings.fps} fps")
|
||||
}
|
||||
DropdownMenu(menu == CallVideoOption.RESOLUTION, onDismissRequest = { menu = null }) {
|
||||
state.sizes().forEach { size -> CaptureOptionItem("${size.width}×${size.height}",
|
||||
size.width == state.settings.width && size.height == state.settings.height, onClick = {
|
||||
state.modes().forEach { mode -> CaptureOptionItem("${mode.width}×${mode.height} · ${mode.fps} fps",
|
||||
mode.width == state.settings.width && mode.height == state.settings.height && mode.fps == state.settings.fps, onClick = {
|
||||
menu = null
|
||||
LogManager.addLog("INFO", "call", "video toolbar resolution=$size")
|
||||
CallController.setVideo(state.settings.copy(width = size.width, height = size.height))
|
||||
LogManager.addLog("INFO", "call", "video toolbar mode=$mode")
|
||||
CallController.setVideo(state.settings.copy(width = mode.width, height = mode.height, fps = mode.fps))
|
||||
}) }
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user