Browse Source

Add Android call camera and keyframe encoder replacement worker

master
evgeny 5 days ago
parent
commit
04f4d29e4f
  1. 250
      tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallVideoEngine.kt

250
tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallVideoEngine.kt

@ -0,0 +1,250 @@
package com.utun.chat.data
import android.annotation.SuppressLint
import android.content.Context
import android.graphics.ImageFormat
import android.hardware.camera2.CameraCaptureSession
import android.hardware.camera2.CameraCharacteristics
import android.hardware.camera2.CameraDevice
import android.hardware.camera2.CameraManager
import android.hardware.camera2.CaptureRequest
import android.media.ImageReader
import android.os.Handler
import android.os.HandlerThread
import android.os.SystemClock
import android.util.Size
import android.view.Surface
import kotlinx.coroutines.flow.MutableStateFlow
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>)
data class CallVideoSettings(val enabled: Boolean = false, val camera: String = "", val width: Int = 640,
val height: Int = 480, val codec: Int = 0)
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 = "")
/** Сервис владеет capture и RX; экран только выбирает режим и передаёт Surface. */
class CallVideoEngine(private val context: Context, private val id: Long) {
private val mutable = MutableStateFlow(CallVideoState())
val state: StateFlow<CallVideoState> = mutable
private val thread = HandlerThread("call-video-tx").apply { start() }
private val handler = Handler(thread.looper)
private val manager = context.getSystemService(CameraManager::class.java)
private val player = CallVideoPlayer(id) { message -> handler.post { report(message) } }
@Volatile private var stopped = false
private var encoders = emptyMap<Int, String>()
private var caps = ByteArray(80)
private var wanted = CallVideoSettings()
private var active: CallVideoSettings? = null
private var format: CallVideoFormat? = null
private var encoder: CallVideoCodec? = null
private var camera: CameraDevice? = null
private var session: CameraCaptureSession? = null
private var reader: ImageReader? = null
private var opening = false
private var closing = false
private var cameraToken = 0L
private var generation = 0L
private var frame = 0L
private var lastPts = -1L
private var lastKey = -1L
private var keySerial = -1L
private var needKey = true
private var request = 0L
private var pendingRequest = 0L
private var pendingRate = 0
private var confirmingRequest = 0L
private var realtime = false
private var sensorOffset: Long? = null
private var openedAt = 0L
private var reportAt = 0L
private var encodedBytes = 0L
private var encodedFrames = 0
init {
handler.post {
try {
encoders = CallVideoCodec.probe()
if (stopped) return@post
val cameras = CameraLensManager.enumerate(manager).mapNotNull { lens ->
val c = manager.getCameraCharacteristics(lens.cameraId)
val sizes = c.get(CameraCharacteristics.SCALER_STREAM_CONFIGURATION_MAP)?.getOutputSizes(ImageFormat.YUV_420_888)
.orEmpty().filter { it == Size(640, 480) || it == Size(1280, 720) }
if (sizes.isEmpty()) null else CallVideoCamera(lens.cameraId, lens.label, sizes)
}
val bytes = ByteBuffer.wrap(caps).order(ByteOrder.BIG_ENDIAN)
repeat(2) { for (codec in 1..2) {
if (encoders.containsKey(codec)) bytes.putShort(1280).putShort(720).putShort(30).putInt(1280 * 720 * 30)
.putInt(4_000_000).putInt(1_000_000).put(if (codec == 1) 31 else 93).put(0)
else bytes.position(bytes.position() + 20)
} }
check(CallVideoNative.open(id, caps)) { "Video port unavailable" }
wanted = wanted.copy(camera = cameras.firstOrNull()?.id.orEmpty())
mutable.value = mutable.value.copy(ready = true, cameras = cameras, codecs = encoders.keys.toList(), settings = wanted)
report(if (encoders.isEmpty()) "No verified video codec" else "Video ready")
tick()
} catch (e: Exception) { fail("Video setup: ${e.message}") }
}
}
fun surface(surface: Surface?) = player.surface(surface)
fun select(settings: CallVideoSettings) { handler.post {
if (!stopped) { wanted = settings; mutable.value = mutable.value.copy(settings = settings) }
} }
fun range(value: CallVideoBitrateRange) { handler.post {
if (CallVideoNative.range(id, value.minimum * 1000, value.maximum * 1000)) mutable.value = mutable.value.copy(range = value)
else report("Bitrate range rejected")
} }
private fun report(message: String) {
mutable.value = mutable.value.copy(status = message)
LogManager.addLog("INFO", "call", "video call=$id $message")
}
private fun fail(message: String) {
LogManager.addLog("ERROR", "call", "video call=$id $message")
wanted = wanted.copy(enabled = false); mutable.value = mutable.value.copy(settings = wanted, status = message)
closeCamera(); encoder?.close(); encoder = null; active = null
if (confirmingRequest != 0L) CallVideoNative.applied(id, format?.generation ?: 0, confirmingRequest, 0)
confirmingRequest = 0
}
private fun tick() {
if (stopped) return
try {
val s = CallVideoNative.status(id) ?: return
if (s[6] >= 300000 && s[7] >= s[6]) mutable.value = mutable.value.copy(range = CallVideoBitrateRange((s[6] / 1000).toInt(), (s[7] / 1000).toInt()))
if (keySerial != s[0]) { keySerial = s[0]; needKey = true }
if (s[1] == format?.generation && s[2] > request) {
request = s[2]; pendingRequest = request; pendingRate = s[3].toInt()
}
if (!wanted.enabled) {
if (active != null) { closeCamera(); encoder?.close(); encoder = null; active = null; report("Video off") }
} else if (wanted != active && !opening && !closing && (lastKey < 0 || System.nanoTime() / 1000 - lastKey >= 1_000_000)) {
val peer = CallVideoNative.peer(id)
if (peer != null) change(peer)
}
drain()
if (active != null && System.nanoTime() - openedAt > 8_000_000_000 && lastPts < 0) fail("Camera produced no frames")
} catch (e: Exception) { fail("Video worker: ${e.message}") }
if (!stopped) handler.postDelayed(::tick, 10)
}
private fun change(peer: ByteArray) {
val cameraInfo = manager.getCameraCharacteristics(wanted.camera)
val rotation = cameraInfo.get(CameraCharacteristics.SENSOR_ORIENTATION) ?: 0
val desired = CallVideoFormat(++generation, wanted.width, wanted.height, rotation = rotation, codec = wanted.codec)
val selected = CallVideoNative.choose(caps, peer, desired.array()) ?: error("No common codec for selected resolution")
// MediaCodec получает фиксированный 3.1; ряд backend не умеет понижать маркировку level для меньшего размера.
val next = CallVideoFormat.from(selected).let { it.copy(level = if (it.codec == 1) 31 else 93) }
val candidate = CallVideoCodec(next, 1_000_000, checkNotNull(encoders[next.codec]))
closeCamera(); encoder?.close(); encoder = candidate; format = next; active = wanted
pendingRequest = 0; confirmingRequest = 0; request = 0; lastKey = -1; needKey = true; lastPts = -1
check(CallVideoNative.format(id, next.array(), candidate.bitrate)) { "Video format rejected" }
mutable.value = mutable.value.copy(bitrate = candidate.bitrate)
if (!closing) openCamera()
}
@SuppressLint("MissingPermission")
private fun openCamera() {
if (stopped || !wanted.enabled || opening || closing) return
val settings = active ?: return; val f = format ?: return; val token = ++cameraToken
val c = manager.getCameraCharacteristics(settings.camera)
realtime = c.get(CameraCharacteristics.SENSOR_INFO_TIMESTAMP_SOURCE) == CameraCharacteristics.SENSOR_INFO_TIMESTAMP_SOURCE_REALTIME
sensorOffset = null
val nextReader = ImageReader.newInstance(f.width, f.height, ImageFormat.YUV_420_888, 3); reader = nextReader
nextReader.setOnImageAvailableListener({ source ->
val image = source.acquireLatestImage() ?: return@setOnImageAvailableListener
try {
if (stopped || token != cameraToken || !wanted.enabled) return@setOnImageAvailableListener
val now = System.nanoTime()
val offset = if (realtime) now - SystemClock.elapsedRealtimeNanos() else sensorOffset ?: (now - image.timestamp).also {
sensorOffset = it; LogManager.addLog("WARN", "call", "camera timestamp calibrated from arrival id=$id offset=$it")
}
val pts = (image.timestamp + offset) / 1000
if (pts <= lastPts || (lastPts >= 0 && pts - lastPts < 33333)) return@setOnImageAvailableListener
val key = needKey || lastKey < 0 || pts - lastKey >= 1_000_000
if (key && pendingRequest != 0L) {
val requestId = pendingRequest; pendingRequest = 0
try {
val candidate = CallVideoCodec(f, pendingRate, checkNotNull(encoders[f.codec]))
encoder?.close(); encoder = candidate; confirmingRequest = requestId; needKey = true
report("Applying ${pendingRate / 1000} kbit/s on IDR")
} catch (e: Exception) {
CallVideoNative.applied(id, f.generation, requestId, 0); throw e
}
}
if (encoder?.submit(image, pts, key) == true) { lastPts = pts; if (key) lastKey = pts }
drain()
} catch (e: Exception) { fail("Camera encode: ${e.message}") }
finally { image.close() }
}, handler)
opening = true; openedAt = System.nanoTime()
manager.openCamera(settings.camera, object : CameraDevice.StateCallback() {
override fun onOpened(device: CameraDevice) {
opening = false; camera = device
if (stopped || token != cameraToken) { device.close(); return }
try {
device.createCaptureSession(listOf(nextReader.surface), object : CameraCaptureSession.StateCallback() {
override fun onConfigured(s: CameraCaptureSession) {
if (stopped || token != cameraToken) { s.close(); return }
session = s
try {
val rates = c.get(CameraCharacteristics.CONTROL_AE_AVAILABLE_TARGET_FPS_RANGES).orEmpty()
val range = rates.filter { it.upper == 30 }.maxByOrNull { it.lower } ?: error("Camera lacks 30 fps")
val capture = device.createCaptureRequest(CameraDevice.TEMPLATE_RECORD).apply {
addTarget(nextReader.surface); set(CaptureRequest.CONTROL_AE_TARGET_FPS_RANGE, range)
}.build()
s.setRepeatingRequest(capture, null, handler)
report("${if (f.codec == 2) "H.265" else "H.264"} · ${f.width}×${f.height}")
} catch (e: Exception) { fail("Camera request: ${e.message}") }
}
override fun onConfigureFailed(s: CameraCaptureSession) { s.close(); fail("Camera session configuration failed") }
}, handler)
} catch (e: Exception) { fail("Camera session: ${e.message}") }
}
override fun onDisconnected(device: CameraDevice) { opening = false; camera = device; fail("Camera disconnected") }
override fun onError(device: CameraDevice, error: Int) { opening = false; camera = device; fail("Camera error $error") }
override fun onClosed(device: CameraDevice) {
if (camera === device) camera = null
closing = false
if (stopped) thread.quitSafely()
else if (active != null && wanted.enabled) try { openCamera() } catch (e: Exception) { fail("Camera reopen: ${e.message}") }
}
}, handler)
}
private fun drain() {
val codec = encoder ?: return; val f = format ?: return
for (p in codec.drain()) {
encodedBytes += p.bytes.size; encodedFrames++
if (confirmingRequest != 0L && p.key) {
CallVideoNative.applied(id, f.generation, confirmingRequest, codec.bitrate); confirmingRequest = 0
mutable.value = mutable.value.copy(bitrate = codec.bitrate)
LogManager.addLog("INFO", "call", "video bitrate applied id=$id generation=${f.generation} bitrate=${codec.bitrate} pts=${p.pts}")
}
if (needKey && !p.key) continue
val metadata = f.array() + longArrayOf(++frame, p.pts, if (p.key) 1 else 0)
needKey = CallVideoNative.send(id, metadata, p.bytes) != 1
}
val now = System.nanoTime()
if (now - reportAt > 1_000_000_000) {
if (reportAt != 0L) LogManager.addLog("DEBUG", "call", "video output id=$id fps=$encodedFrames " +
"bps=${encodedBytes * 8_000_000_000 / (now - reportAt)} target=${codec.bitrate} needKey=$needKey")
reportAt = now; encodedFrames = 0; encodedBytes = 0
}
}
private fun closeCamera() {
cameraToken++
try { session?.close(); session = null; reader?.close(); reader = null
if (camera != null) { closing = true; camera!!.close() }
} catch (e: Exception) { LogManager.addLog("WARN", "call", "camera close: ${e.message}") }
}
fun stop() {
if (stopped) return
stopped = true; player.stop(); CallVideoNative.close(id)
handler.post {
closeCamera(); encoder?.close(); encoder = null
if (camera == null && !opening) thread.quitSafely()
else handler.postDelayed({ thread.quitSafely() }, 3000)
LogManager.addLog("INFO", "call", "video workers stopped id=$id")
}
}
}
Loading…
Cancel
Save