@ -3,16 +3,18 @@ package com.utun.chat.data
import android.annotation.SuppressLint
import android.content.Context
import android.hardware.camera2.CameraCaptureSession
import android.hardware.camera2.CameraConstrainedHighSpeedCaptureSession
import android.hardware.camera2.CameraCharacteristics
import android.hardware.camera2.CameraDevice
import android.hardware.camera2.CameraManager
import android.hardware.camera2.CaptureRequest
import android.hardware.camera2.CaptureResult
import android.hardware.camera2.TotalCaptureResult
import android.media.AudioFormat
import android.media.AudioRecord
import android.media.AudioTimestamp
import android.media.MediaCodec
import android.media.MediaCodecInfo
import android.media.MediaCodecList
import android.media.MediaFormat
import android.media.MediaRecorder
import android.os.Bundle
@ -22,13 +24,12 @@ import android.util.Range
import android.view.Surface
import java.io.ByteArrayOutputStream
/ * * Сервис владеет камерой / кодировщиком ; локальный preview — обычный reader собственного стрима .
* Камера MediaCodec Surface и AudioTimestamp используют CLOCK _MONOTONIC ; PTS относительно общего originNs .
* Кодек / камера — один HandlerThread , Opus — отдельный audio thread . UI не владеет ресурсами захвата . * /
/ * * Camera / codec принадлежат HandlerThread ; микрофон и origin сохраняются при смене видео .
* Preview читает тот же источник . Поколение отсеивает callbacks закрываемой камеры / кодека . * /
class StreamCaptureEngine (
private val context : Context , private val id : Long , private val group : Long ,
private val cameraId : Strin g , private val microphone : Boolean , private val displayRotation : Int ,
private val failure : ( String ) -> Unit
private val initialConfig : StreamCaptureConf ig , private val microphone : Boolean , private val preview : StreamPreview ,
private val ready : ( StreamCaptureConfig , Boolean ) -> Unit , private val failure : ( String ) -> Unit
) {
private val thread = HandlerThread ( " stream-camera " ) . apply { start ( ) }
private val handler = Handler ( thread . looper )
@ -40,9 +41,21 @@ class StreamCaptureEngine(
private var record : AudioRecord ? = null
private var audioThread : Thread ? = null
@Volatile private var audioRunning = false
private var stopped = false
@Volatile private var stopped = false
private var stopping = false
private var stopDone : ( ( ) -> Unit ) ? = null
private var terminalFailure = false
private val stopCallbacks = mutableListOf < ( ) -> Unit > ( )
private var opening = false
private var openingGeneration = 0L
private var releaseDone : ( ( Boolean ) -> Unit ) ? = null
private var generation = 0L
private var lastGood : StreamCaptureConfig ? = null
private var reverting = false
private var waitingFirst = true
private var confirming = false
private var confirmStartNs = 0L
private var confirmFrames = 0L
private var switchStartedNs = 0L
private var originNs = 0L
private var csd = byteArrayOf ( )
private val partial = ByteArrayOutputStream ( )
@ -51,109 +64,198 @@ class StreamCaptureEngine(
private var requireKey = true
private var lastKeyRequest = 0L
private var frames = 0L
private var dropped = 0L
private var encodedFrames = 0L
private var bytesSent = 0L
private var reportNs = 0L
private var reportFrames = 0L
private var reportBytes = 0L
private var lastPts = - 1L
fun start ( ) { handler . post { try { configure ( ) } catch ( e : Exception ) { failed ( " setup: ${e.message} " ) } } }
fun start ( ) {
handler . post {
if ( stopping || stopped ) return @post
try {
val mode = initialConfig . mode
val rotation = cameraPose ( mode . camera ) . rotation ( initialConfig . orientation . degrees )
check ( NativeLib . streamOpen ( id , group , width = mode . width , height = mode . height ,
rotation = rotation , audio = microphone ) ) { " Publisher open rejected " }
originNs = System . nanoTime ( )
if ( microphone ) startAudio ( )
configure ( initialConfig )
} catch ( e : Exception ) { failed ( " setup: ${e.message} " ) }
}
}
@SuppressLint ( " MissingPermission " )
private fun configure ( ) {
if ( stopping || stopped ) return
val characteristics = manager . getCameraCharacteristics ( cameraId )
val sizes = characteristics . get ( CameraCharacteristics . SCALER _STREAM _CONFIGURATION _MAP )
?. getOutputSizes ( MediaCodec :: class . java ) ?. toList ( ) . orEmpty ( )
val info = MediaCodecList ( MediaCodecList . REGULAR _CODECS ) . codecInfos . firstOrNull { codec ->
codec . isEncoder && codec . supportedTypes . any { it . equals ( MediaFormat . MIMETYPE _VIDEO _AVC , true ) } &&
codec . getCapabilitiesForType ( MediaFormat . MIMETYPE _VIDEO _AVC ) . profileLevels . any {
it . profile == MediaCodecInfo . CodecProfileLevel . AVCProfileBaseline
} && sizes . any { s -> s . width <= 1280 && s . height <= 720 &&
codec . getCapabilitiesForType ( MediaFormat . MIMETYPE _VIDEO _AVC ) . videoCapabilities . areSizeAndRateSupported ( s . width , s . height , 30.0 ) }
} ?: error ( " No AVC baseline camera/encoder combination at 30 fps " )
val size = sizes . filter { it . width <= 1280 && it . height <= 720 &&
info . getCapabilitiesForType ( MediaFormat . MIMETYPE _VIDEO _AVC ) . videoCapabilities . areSizeAndRateSupported ( it . width , it . height , 30.0 ) }
. maxByOrNull { it . width * it . height } ?: error ( " No compatible camera size " )
val front = characteristics . get ( CameraCharacteristics . LENS _FACING ) == CameraCharacteristics . LENS _FACING _FRONT
val sensor = characteristics . get ( CameraCharacteristics . SENSOR _ORIENTATION ) ?: 0
val rotation = ( sensor + if ( front ) displayRotation else - displayRotation + 360 ) % 360
val format = MediaFormat . createVideoFormat ( MediaFormat . MIMETYPE _VIDEO _AVC , size . width , size . height ) . apply {
setInteger ( MediaFormat . KEY _COLOR _FORMAT , MediaCodecInfo . CodecCapabilities . COLOR _FormatSurface )
setInteger ( MediaFormat . KEY _PROFILE , MediaCodecInfo . CodecProfileLevel . AVCProfileBaseline )
setInteger ( MediaFormat . KEY _BIT _RATE , if ( size . width * size . height >= 1280 * 720 ) 2 _000 _000 else 1 _000 _000 )
setInteger ( MediaFormat . KEY _FRAME _RATE , 30 ) ; setInteger ( MediaFormat . KEY _I _FRAME _INTERVAL , 1 )
fun change ( config : StreamCaptureConfig ) {
handler . post {
if ( stopping || stopped || terminalFailure || waitingFirst || confirming || releaseDone != null ) {
LogManager . addLog ( " WARN " , " video " , " capture change rejected id= $id busy= $waitingFirst stopping= $stopping " ) ; return @post
}
if ( config == lastGood ) { ready ( config , false ) ; return @post }
waitingFirst = true ; reverting = false ; switchStartedNs = System . nanoTime ( )
LogManager . addLog ( " INFO " , " video " , " switch capture id= $id camera= ${config.mode.camera} ${config.mode.label} orientation= ${config.orientation.degrees} " )
releaseVideo { closed -> if ( closed ) configure ( config ) else failed ( " camera close timed out during switch " ) }
}
check ( NativeLib . streamOpen ( id , group , width = size . width , height = size . height , rotation = rotation , audio = microphone ) )
originNs = System . nanoTime ( )
encoder = MediaCodec . createByCodecName ( info . name )
encoder !! . also { codec ->
codec . setCallback ( object : MediaCodec . Callback ( ) {
override fun onInputBufferAvailable ( codec : MediaCodec , index : Int ) { }
override fun onError ( codec : MediaCodec , error : MediaCodec . CodecException ) { failed ( " encoder: ${error.diagnosticInfo} " ) }
override fun onOutputFormatChanged ( codec : MediaCodec , format : MediaFormat ) {
try {
val parts = listOf ( " csd-0 " , " csd-1 " ) . mapNotNull { name -> format . getByteBuffer ( name ) ?. duplicate ( ) ?. let { b ->
ByteArray ( b . remaining ( ) ) . also { b . get ( it ) } . let ( H264AnnexB :: normalize )
} }
csd = parts . fold ( byteArrayOf ( ) ) { a , b -> a + b }
val types = H264AnnexB . nals ( csd ) . map { it . first } . toSet ( )
check ( 7 in types && 8 in types ) { " AVC format missing SPS/PPS " }
LogManager . addLog ( " INFO " , " video " , " encoder format id= $id csd= ${csd.size} name= ${codec.name} " )
} catch ( e : Exception ) { failed ( " encoder format: ${e.message} " ) }
}
override fun onOutputBufferAvailable ( codec : MediaCodec , index : Int , info : MediaCodec . BufferInfo ) {
if ( stopped ) return
try {
if ( info . size > 0 ) {
val buffer = codec . getOutputBuffer ( index ) ?: error ( " Missing AVC output " )
buffer . position ( info . offset ) ; buffer . limit ( info . offset + info . size )
val bytes = ByteArray ( info . size ) . also { buffer . get ( it ) }
if ( info . flags and MediaCodec . BUFFER _FLAG _CODEC _CONFIG != 0 ) {
val config = H264AnnexB . normalize ( bytes )
val types = H264AnnexB . nals ( config ) . map { it . first } . toSet ( )
if ( 7 in types && 8 in types ) csd = config
} else encoded ( bytes , info )
}
private fun cameraPose ( cameraId : String ) : StreamPreviewCamera {
val c = manager . getCameraCharacteristics ( cameraId )
return StreamPreviewCamera ( c . get ( CameraCharacteristics . SENSOR _ORIENTATION ) ?: 0 ,
c . get ( CameraCharacteristics . LENS _FACING ) == CameraCharacteristics . LENS _FACING _FRONT )
}
@SuppressLint ( " MissingPermission " )
private fun configure ( config : StreamCaptureConfig ) {
if ( stopping || stopped || terminalFailure ) return
val mode = config . mode
val token = ++ generation
try {
check ( token <= 0xffffffffL ) { " Video revision exhausted " }
val cameraPose = cameraPose ( mode . camera )
val rotation = cameraPose . rotation ( config . orientation . degrees )
preview . register ( token , cameraPose )
LogManager . addLog ( " DEBUG " , " video " , " preview camera id= $id revision= $token camera= ${mode.camera} " +
" sensor= ${cameraPose.sensor} front= ${cameraPose.front} broadcastRotation= $rotation " )
csd = byteArrayOf ( ) ; partial . reset ( ) ; requireKey = true ; lastKeyRequest = 0 ; waitingFirst = true
confirming = mode . highSpeed ; confirmStartNs = 0
reportNs = System . nanoTime ( ) ; reportFrames = encodedFrames ; reportBytes = bytesSent
if ( switchStartedNs == 0L ) switchStartedNs = reportNs
val format = MediaFormat . createVideoFormat ( MediaFormat . MIMETYPE _VIDEO _AVC , mode . width , mode . height ) . apply {
setInteger ( MediaFormat . KEY _COLOR _FORMAT , MediaCodecInfo . CodecCapabilities . COLOR _FormatSurface )
setInteger ( MediaFormat . KEY _PROFILE , MediaCodecInfo . CodecProfileLevel . AVCProfileBaseline )
setInteger ( MediaFormat . KEY _BIT _RATE , mode . bitrate )
setInteger ( MediaFormat . KEY _FRAME _RATE , mode . fps ) ; setInteger ( MediaFormat . KEY _I _FRAME _INTERVAL , 1 )
}
encoder = MediaCodec . createByCodecName ( mode . codec )
encoder !! . also { codec ->
codec . setCallback ( object : MediaCodec . Callback ( ) {
override fun onInputBufferAvailable ( codec : MediaCodec , index : Int ) { }
override fun onError ( codec : MediaCodec , error : MediaCodec . CodecException ) { captureError ( token , " encoder: ${error.diagnosticInfo} " ) }
override fun onOutputFormatChanged ( codec : MediaCodec , format : MediaFormat ) {
if ( ! current ( token ) ) return
try {
csd = listOf ( " csd-0 " , " csd-1 " ) . mapNotNull { name -> format . getByteBuffer ( name ) ?. duplicate ( ) ?. let { b ->
ByteArray ( b . remaining ( ) ) . also { b . get ( it ) } . let ( H264AnnexB :: normalize )
} } . fold ( byteArrayOf ( ) ) { a , b -> a + b }
check ( H264AnnexB . nals ( csd ) . map { it . first } . toSet ( ) . containsAll ( setOf ( 7 , 8 ) ) ) { " AVC format missing SPS/PPS " }
LogManager . addLog ( " INFO " , " video " , " encoder format id= $id revision= $token codec= ${codec.name} csd= ${csd.size} format= $format " )
} catch ( e : Exception ) { captureError ( token , " encoder format: ${e.message} " ) }
}
override fun onOutputBufferAvailable ( codec : MediaCodec , index : Int , info : MediaCodec . BufferInfo ) {
if ( ! current ( token ) ) return
var problem : String ? = null
try {
if ( info . size > 0 ) {
val buffer = codec . getOutputBuffer ( index ) ?: error ( " Missing AVC output " )
buffer . position ( info . offset ) ; buffer . limit ( info . offset + info . size )
val bytes = ByteArray ( info . size ) . also { buffer . get ( it ) }
if ( info . flags and MediaCodec . BUFFER _FLAG _CODEC _CONFIG != 0 ) {
val codecConfig = H264AnnexB . normalize ( bytes )
if ( H264AnnexB . nals ( codecConfig ) . map { it . first } . toSet ( ) . containsAll ( setOf ( 7 , 8 ) ) ) csd = codecConfig
} else encoded ( bytes , info , config , rotation , token )
}
if ( info . flags and MediaCodec . BUFFER _FLAG _END _OF _STREAM != 0 ) problem = " unexpected encoder EOS "
} catch ( e : Exception ) { problem = " encoded frame: ${e.message} " }
finally {
try { codec . releaseOutputBuffer ( index , false ) }
catch ( e : Exception ) { problem = " encoder output release: ${e.message} " }
}
} catch ( e : Exception ) { failed ( " encoded frame: ${e.message} " ) }
finally { if ( ! stopped ) codec . releaseOutputBuffer ( index , false ) }
if ( info . flags and MediaCodec . BUFFER _FLAG _END _OF _STREAM != 0 ) {
if ( stopping ) finishStop ( ) else failed ( " unexpected encoder EOS " )
problem ?. let { captureError ( token , it ) }
}
}
} , handler )
codec . configure ( format , null , null , MediaCodec . CONFIGURE _FLAG _ENCODE )
input = codec . createInputSurface ( ) ; codec . start ( )
}
if ( microphone ) startAudio ( )
manager . openCamera ( cameraId , object : CameraDevice . StateCallback ( ) {
override fun onOpened ( device : CameraDevice ) {
if ( stopping || stopped ) { device . close ( ) ; return }
camera = device
device . createCaptureSession ( listOf ( input !! ) , object : CameraCaptureSession . StateCallback ( ) {
override fun onConfigured ( configured : CameraCaptureSession ) {
if ( stopping || stopped ) { configured . close ( ) ; return }
session = configured
} , handler )
codec . configure ( format , null , null , MediaCodec . CONFIGURE _FLAG _ENCODE )
input = codec . createInputSurface ( ) ; codec . start ( )
}
opening = true ; openingGeneration = token
try {
manager . openCamera ( mode . camera , object : CameraDevice . StateCallback ( ) {
override fun onOpened ( device : CameraDevice ) {
if ( openingGeneration == token ) { opening = false ; camera = device }
if ( ! current ( token ) ) { device . close ( ) ; return }
try {
val request = device . createCaptureRequest ( CameraDevice . TEMPLATE _RECORD ) . apply {
addTarget ( input !! )
val rates = characteristics . get ( CameraCharacteristics . CONTROL _AE _AVAILABLE _TARGET _FPS _RANGES ) . orEmpty ( )
rates . filter { it . upper == 30 } . minByOrNull { it . upper - it . lower } ?. let {
set ( CaptureRequest . CONTROL _AE _TARGET _FPS _RANGE , Range ( it . lower , it . upper ) )
val callback = object : CameraCaptureSession . StateCallback ( ) {
override fun onConfigured ( configured : CameraCaptureSession ) {
if ( ! current ( token ) ) { configured . close ( ) ; return }
session = configured
try {
val request = device . createCaptureRequest ( CameraDevice . TEMPLATE _RECORD ) . apply {
addTarget ( input !! )
set ( CaptureRequest . CONTROL _AE _TARGET _FPS _RANGE , Range ( mode . minimumFps , mode . fps ) )
} . build ( )
var sensorResults = 0L
var sensorReportCount = 0L
var sensorReportNs = System . nanoTime ( )
val diagnostics = object : CameraCaptureSession . CaptureCallback ( ) {
override fun onCaptureCompleted ( session : CameraCaptureSession , request : CaptureRequest , result : TotalCaptureResult ) {
if ( ! current ( token ) ) return
sensorResults ++
val now = System . nanoTime ( )
if ( now - sensorReportNs >= 1 _000 _000 _000 ) {
val fps = ( sensorResults - sensorReportCount ) * 1 _000 _000 _000 . 0 / ( now - sensorReportNs )
LogManager . addLog ( " DEBUG " , " video " , " sensor id= $id revision= $token results= $sensorResults callbackFps= ${"%.1f".format(fps)} " +
" range= ${result.get(CaptureResult.CONTROL_AE_TARGET_FPS_RANGE)} " +
" durationNs= ${result.get(CaptureResult.SENSOR_FRAME_DURATION)} " +
" exposureNs= ${result.get(CaptureResult.SENSOR_EXPOSURE_TIME)} timestamp= ${result.get(CaptureResult.SENSOR_TIMESTAMP)} " )
sensorReportNs = now ; sensorReportCount = sensorResults
}
}
override fun onCaptureFailed ( session : CameraCaptureSession , request : CaptureRequest ,
failed : android . hardware . camera2 . CaptureFailure ) {
if ( current ( token ) ) LogManager . addLog ( " WARN " , " video " , " sensor capture failed id= $id revision= $token " +
" frame= ${failed.frameNumber} reason= ${failed.reason} captured= ${failed.wasImageCaptured()} " )
}
}
if ( mode . highSpeed ) {
val highSpeed = configured as CameraConstrainedHighSpeedCaptureSession
highSpeed . setRepeatingBurst ( highSpeed . createHighSpeedRequestList ( request ) , diagnostics , handler )
} else configured . setRepeatingRequest ( request , diagnostics , handler )
LogManager . addLog ( " INFO " , " video " , " capture started id= $id revision= $token camera= ${mode.camera} ${mode.label} " +
" range= ${mode.minimumFps} .. ${mode.fps} highSpeed= ${mode.highSpeed} codec= ${mode.codec} " +
" bitrate= ${mode.bitrate} rotation= $rotation orientation= ${config.orientation.degrees} audio= $microphone origin= $originNs " )
} catch ( e : Exception ) { captureError ( token , " camera request: ${e.message} " ) }
}
} . build ( )
configured . setRepeatingRequest ( request , null , handler )
LogManager . addLog ( " INFO " , " video " , " capture started id= $id camera= $cameraId ${size.width} x ${size.height} rotation= $rotation " +
" codec= ${info.name} audio= $microphone timebase=MONOTONIC origin= $originNs " )
} catch ( e : Exception ) { failed ( " camera request: ${e.message} " ) }
override fun onConfigureFailed ( session : CameraCaptureSession ) {
session . close ( ) ; captureError ( token , " camera session configuration " )
}
}
if ( mode . highSpeed ) device . createConstrainedHighSpeedCaptureSession ( listOf ( input !! ) , callback , handler )
else device . createCaptureSession ( listOf ( input !! ) , callback , handler )
} catch ( e : Exception ) { captureError ( token , " camera session: ${e.message} " ) }
}
override fun onClosed ( device : CameraDevice ) {
if ( camera === device ) camera = null
if ( releaseDone != null && camera == null && ! opening ) finishRelease ( true )
if ( stopped && camera == null && ! opening ) thread . quitSafely ( )
}
override fun onDisconnected ( device : CameraDevice ) {
if ( openingGeneration == token ) { opening = false ; camera = device }
device . close ( ) ; captureError ( token , " camera disconnected " )
}
override fun onError ( device : CameraDevice , error : Int ) {
if ( openingGeneration == token ) { opening = false ; camera = device }
device . close ( ) ; captureError ( token , " camera error= $error " )
}
override fun onConfigureFailed ( session : CameraCaptureSession ) { session . close ( ) ; failed ( " camera session configuration " ) }
} , handler )
}
override fun onDisconnected ( device : CameraDevice ) { device . close ( ) ; failed ( " camera disconnected " ) }
override fun onError ( device : CameraDevice , error : Int ) { device . close ( ) ; failed ( " camera error= $error " ) }
} , handler )
} catch ( e : Exception ) { opening = false ; throw e }
handler . postDelayed ( { if ( current ( token ) && ( waitingFirst || confirming ) ) captureError ( token , " keyframe/capture rate validation timeout " ) } , 8000 )
} catch ( e : Exception ) { captureError ( token , " video setup: ${e.message} " ) }
}
private fun encoded ( bytes : ByteArray , info : MediaCodec . BufferInfo ) {
private fun current ( token : Long ) = token == generation && ! stopping && ! stopped && ! terminalFailure && releaseDone == null
private fun captureError ( token : Long , detail : String ) {
if ( ! current ( token ) ) { LogManager . addLog ( " DEBUG " , " video " , " stale capture error revision= $token : $detail " ) ; return }
LogManager . addLog ( " ERROR " , " video " , " capture error id= $id revision= $token : $detail " )
val previous = lastGood
if ( previous == null || reverting ) { failed ( detail ) ; return }
waitingFirst = true ; reverting = true
LogManager . addLog ( " WARN " , " video " , " restoring camera= ${previous.mode.camera} ${previous.mode.label} orientation= ${previous.orientation.degrees} id= $id " )
releaseVideo { closed -> if ( closed ) configure ( previous ) else failed ( " camera close timed out during restore " ) }
}
private fun encoded ( bytes : ByteArray , info : MediaCodec . BufferInfo , config : StreamCaptureConfig , rotation : Int , revision : Long ) {
val mode = config . mode
if ( partial . size ( ) == 0 ) { partialPts = info . presentationTimeUs ; partialKey = false }
check ( info . presentationTimeUs == partialPts ) { " Partial AVC frame PTS changed " }
check ( partial . size ( ) + bytes . size <= H264AnnexB . MAX _FRAME ) { " AVC frame exceeds limit " }
@ -161,31 +263,84 @@ class StreamCaptureEngine(
if ( info . flags and MediaCodec . BUFFER _FLAG _PARTIAL _FRAME != 0 ) return
val data = H264AnnexB . normalize ( partial . toByteArray ( ) ) ; partial . reset ( )
val key = partialKey && H264AnnexB . nals ( data ) . any { it . first == 5 }
if ( requireKey && ! key ) { requestKey ( ) ; return }
val output = if ( key ) H264AnnexB . keyFrame ( data , csd ) else data
val pts = partialPts - originNs / 1000
check ( pts >= 0 && pts > lastPts ) { " AVC PTS outside monotonic timeline pts= $pts last= $lastPts " }
lastPts = pts
when ( NativeLib . streamFeed ( id , true , pts , key , output ) ) {
1 -> { requireKey = false ; frames ++ ; bytesSent += output . size }
0 -> { requireKey = true ; requestKey ( ) }
else -> failed ( " stream publisher detached " )
check ( pts >= 0 && pts > lastPts ) { " AVC PTS outside timeline pts= $pts last= $lastPts " }
lastPts = pts ; encodedFrames ++
if ( requireKey && ! key ) { dropped ++ ; requestKey ( ) }
else {
val output = if ( key ) H264AnnexB . keyFrame ( data , csd ) else data
when ( NativeLib . streamFeed ( id , true , pts , key , output , revision , mode . width , mode . height , rotation , microphone ) ) {
1 -> {
requireKey = false ; frames ++ ; bytesSent += output . size
if ( waitingFirst ) {
check ( key ) ; waitingFirst = false
confirmStartNs = System . nanoTime ( ) ; confirmFrames = encodedFrames
if ( ! confirming ) confirmed ( config , revision , pts )
}
}
0 -> { dropped ++ ; requireKey = true ; requestKey ( ) }
else -> failed ( " stream publisher detached " )
}
}
val now = System . nanoTime ( )
if ( confirming && confirmStartNs != 0L && now - confirmStartNs >= 3 _000 _000 _000 ) {
val actualFps = ( encodedFrames - confirmFrames ) * 1 _000 _000 _000 . 0 / ( now - confirmStartNs )
if ( actualFps < mode . fps * 0.8 ) {
captureError ( revision , " high speed rate insufficient: camera= ${mode.camera} target= ${mode.fps} actual= ${"%.1f".format(actualFps)} fps " )
return
}
confirmed ( config , revision , pts )
}
if ( now - reportNs >= 1 _000 _000 _000 ) {
reportNs = now
LogManager . addLog ( " DEBUG " , " video " , " capture id= $id frames= $frames bytes= $bytesSent pts= $pts key= $key waitingKey= $requireKey " )
val seconds = ( now - reportNs ) / 1 _000 _000 _000 . 0
LogManager . addLog ( " DEBUG " , " video " , " capture id= $id revision= $revision targetFps= ${mode.fps} " +
" fps= ${"%.1f".format((encodedFrames - reportFrames) / seconds)} bitrate= ${((bytesSent - reportBytes) * 8 / seconds).toLong()} " +
" frames= $frames dropped= $dropped pts= $pts waitingKey= $requireKey " )
reportNs = now ; reportFrames = encodedFrames ; reportBytes = bytesSent
}
}
/** Новый скоростной режим подтверждается реальной скоростью; до этого lastGood сохраняется для rollback. */
private fun confirmed ( config : StreamCaptureConfig , revision : Long , pts : Long ) {
val mode = config . mode
confirming = false ; lastGood = config
LogManager . addLog ( " INFO " , " video " , " capture ready id= $id revision= $revision camera= ${mode.camera} ${mode.label} " +
" orientation= ${config.orientation.degrees} switchMs= ${(System.nanoTime() - switchStartedNs) / 1_000_000} restored= $reverting pts= $pts " )
ready ( config , reverting ) ; reverting = false ; switchStartedNs = 0
}
private fun requestKey ( ) {
val now = System . nanoTime ( )
if ( now - lastKeyRequest < 500 _000 _000 ) return
lastKeyRequest = now
encoder ?. setParameters ( Bundle ( ) . apply { putInt ( MediaCodec . PARAMETER _KEY _REQUEST _SYNC _FRAME , 0 ) } )
LogManager . addLog ( " INFO " , " video " , " requested AVC keyframe id= $id " )
LogManager . addLog ( " INFO " , " video " , " requested AVC keyframe id= $id revision= $generation ")
}
/** Новая камера открывается только после onClosed; старые codec callbacks уже отсечены. */
private fun releaseVideo ( done : ( Boolean ) -> Unit ) {
if ( releaseDone != null ) { releaseDone = done ; return }
val token = ++ generation
releaseDone = done
try { session ?. close ( ) } catch ( e : Exception ) { LogManager . addLog ( " WARN " , " video " , " camera session close id= $id : ${e.message} " ) }
session = null
try { camera ?. close ( ) } catch ( e : Exception ) { LogManager . addLog ( " WARN " , " video " , " camera close id= $id : ${e.message} " ) }
if ( camera == null && ! opening ) finishRelease ( true )
else handler . postDelayed ( {
if ( releaseDone != null && generation == token ) {
LogManager . addLog ( " ERROR " , " video " , " camera close timeout id= $id opening= $opening " ) ; finishRelease ( false )
}
} , 3000 )
}
private fun finishRelease ( closed : Boolean ) {
val done = releaseDone ?: return
try { encoder ?. stop ( ) } catch ( e : Exception ) { LogManager . addLog ( " WARN " , " video " , " encoder stop id= $id : ${e.message} " ) }
try { encoder ?. release ( ) } catch ( e : Exception ) { LogManager . addLog ( " WARN " , " video " , " encoder release id= $id : ${e.message} " ) }
encoder = null ; input ?. release ( ) ; input = null ; partial . reset ( ) ; csd = byteArrayOf ( ) ; releaseDone = null
LogManager . addLog ( " DEBUG " , " video " , " video resources released id= $id closed= $closed revision= $generation " )
done ( closed )
}
@SuppressLint ( " MissingPermission " )
private fun startAudio ( ) {
val min = AudioRecord . getMinBufferSize ( 48000 , AudioFormat . CHANNEL _IN _MONO , AudioFormat . ENCODING _PCM _16BIT )
@ -225,35 +380,33 @@ class StreamCaptureEngine(
}
private fun failed ( detail : String ) {
if ( stopping || stopped ) return
if ( stopping || stopped || terminalFailure ) return
terminalFailure = true
LogManager . addLog ( " ERROR " , " stream " , " capture failed id= $id $detail " )
failure ( detail )
}
/** Сначала прекращаем producers, затем EOS; completion разрешает native FIN . */
/** Producers закрываются до native FIN; уже принятые кадры дренирует native publisher . */
fun stop ( done : ( ) -> Unit ) {
handler . post {
if ( stopped ) { done ( ) ; return }
if ( ! handler . post ( {
if ( stopped ) { done ( ) ; return @post }
if ( stopping ) { LogManager . addLog ( " DEBUG " , " stream " , " duplicate capture stop id= $id " ) ; return @post }
stopping = true ; stopDone = done ; audioRunning = false
stopCallbacks . add ( done )
if ( stopping ) return @post
stopping = true ; audioRunning = false
try { record ?. stop ( ) } catch ( e : Exception ) { LogManager . addLog ( " WARN " , " stream " , " audio stop id= $id : ${e.message} " ) }
audioThread ?. join ( ) ; audioThread = null
record ?. release ( ) ; record = null
session ?. close ( ) ; session = null ; camera ?. close ( ) ; camera = null
try {
if ( encoder != null ) { encoder !! . signalEndOfInputStream ( ) ; handler . postDelayed ( { finishStop ( ) } , 1000 ) }
else finishStop ( )
} catch ( e : Exception ) { LogManager . addLog ( " WARN " , " stream " , " encoder EOS id= $id : ${e.message} " ) ; finishStop ( ) }
try { record ?. release ( ) } catch ( e : Exception ) { LogManager . addLog ( " WARN " , " stream " , " audio release id= $id : ${e.message} " ) }
record = null
releaseVideo {
stopped = true
LogManager . addLog ( " INFO " , " stream " , " capture released id= $id frames= $frames dropped= $dropped bytes= $bytesSent " )
stopCallbacks . toList ( ) . also { stopCallbacks . clear ( ) } . forEach { it ( ) }
if ( camera == null && ! opening ) thread . quitSafely ( )
}
} ) ) {
LogManager . addLog ( " WARN " , " stream " , " capture handler already closed id= $id ; complete stop " )
done ( )
}
}
private fun finishStop ( ) {
if ( stopped ) return
stopped = true
try { encoder ?. stop ( ) } catch ( e : Exception ) { LogManager . addLog ( " WARN " , " video " , " encoder stop id= $id : ${e.message} " ) }
try { encoder ?. release ( ) } catch ( e : Exception ) { LogManager . addLog ( " WARN " , " video " , " encoder release id= $id : ${e.message} " ) }
encoder = null ; input ?. release ( ) ; input = null ; partial . reset ( )
LogManager . addLog ( " INFO " , " stream " , " capture released id= $id frames= $frames bytes= $bytesSent " )
stopDone ?. invoke ( ) ; stopDone = null ; thread . quitSafely ( )
}
}