diff --git a/src/call/call_video_port.c b/src/call/call_video_port.c index aa9a2d9a..2ba0a943 100644 --- a/src/call/call_video_port.c +++ b/src/call/call_video_port.c @@ -243,7 +243,8 @@ int call_video_port_send(const struct call_video_frame *frames, unsigned count) uint32_t bytes = 0; for (unsigned i = 0; i < count; ++i) { const struct call_video_frame *f = &frames[i]; - if (!f->data || !f->size || f->size > CALL_VIDEO_MAX_FRAME - bytes || f->offset || f->length != f->size + if (!f->data || !f->size || f->size > CALL_VIDEO_MAX_FRAME || f->size > CALL_VIDEO_PORT_TX_BYTES - bytes + || f->offset || f->length != f->size || f->call_id != frames[0].call_id || !call_video_same_format(&f->format, &frames[0].format) || (i && (f->frame_id <= frames[i - 1].frame_id || f->pts_us <= frames[i - 1].pts_us))) { DEBUG_ERROR(DEBUG_CATEGORY_CALL, "video port invalid TX batch frame=%u count=%u bytes=%u", i, count, bytes); return -1; diff --git a/src/call/call_video_port.h b/src/call/call_video_port.h index 89f656fc..870ae10e 100644 --- a/src/call/call_video_port.h +++ b/src/call/call_video_port.h @@ -1,6 +1,6 @@ /* Один платформенный видеозвонок процесса. Attach/detach — uasync; остальные API * потокобезопасны. Detach отменяет post до уничтожения ядра. Worker не хранит inst. - * Один TX mailbox (готовая пачка до восьми кадров <=2MiB) + восемь RX кадров <=4MiB; + * Один TX mailbox (до восьми кадров, каждый <=2MiB) + восемь RX кадров <=4MiB; * владение pull.data передаётся через u_free. TX не ждёт накопления пачки. * Никаких callbacks в UI: status/pull читает worker. Кодирование не блокирует uasync. */ #ifndef CALL_VIDEO_PORT_H @@ -28,6 +28,7 @@ int call_video_port_status(uint64_t id, struct call_video_port_status *out); int call_video_port_format(uint64_t id, const struct call_video_format *format, uint32_t bps); int call_video_port_applied(uint64_t id, uint64_t generation, uint64_t request, uint32_t bps); #define CALL_VIDEO_PORT_TX_FRAMES 8u +#define CALL_VIDEO_PORT_TX_BYTES (CALL_VIDEO_PORT_TX_FRAMES * CALL_VIDEO_MAX_FRAME) /* Атомарно копирует готовую пачку одного звонка. 1 — принята целиком, 0 — mailbox занят, * -1 — ошибка; на 0/-1 ни один кадр не принят. count: 1..CALL_VIDEO_PORT_TX_FRAMES. */ int call_video_port_send(const struct call_video_frame *frames, unsigned count); diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallVideoEngine.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallVideoEngine.kt index e4c07a9d..e84f1cb4 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallVideoEngine.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallVideoEngine.kt @@ -118,6 +118,7 @@ class CallVideoEngine(private val context: Context, private val id: Long) { fun surface(surface: Surface?, release: () -> Unit = {}) = player.surface(surface, release) fun detachSurface(surface: Surface) = player.detach(surface) + fun presented(surface: Surface, pts: Long) = player.presented(surface, pts) fun select(settings: CallVideoSettings) { handler.post { if (!stopped) { wanted = settings; excluded = emptySet(); mutable.value = mutable.value.copy(settings = settings) } } } diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallVideoPlayer.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallVideoPlayer.kt index 6d94dd39..7c256d76 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallVideoPlayer.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallVideoPlayer.kt @@ -12,6 +12,9 @@ internal class CallVideoPlayer(private val id: Long, private val failure: (Strin @Volatile private var running = true private data class Output(val surface: Surface?, val release: () -> Unit, val detach: Surface? = null) private val outputs = ConcurrentLinkedQueue() + private data class Presentation(val surface: Surface, val pts: Long) + private val presentations = ConcurrentLinkedQueue() + fun presented(surface: Surface, pts: Long) { if (running) presentations.add(Presentation(surface, pts)) } private val displayed = MutableStateFlow(null) val display: StateFlow = displayed @Volatile private var verified = emptyMap() @@ -32,6 +35,7 @@ internal class CallVideoPlayer(private val id: Long, private val failure: (Strin if (playout == 0L) { failure("Video playout allocation failed"); return } var decoder: MediaCodec? = null; var surface: Surface? = null; var format: CallVideoFormat? = null var output: Output? = null; var lastRendered = 0L + var awaitingPts = -1L var pending: ByteArray? = null; val meta = LongArray(13) val pictures = ArrayDeque(); val inputs = ArrayDeque() var draining = false; var endSeen = false @@ -43,6 +47,7 @@ internal class CallVideoPlayer(private val id: Long, private val failure: (Strin try { decoder?.stop() } catch (e: Exception) { LogManager.addLog("WARN", "call", "video decoder stop: ${e.message}") } try { decoder?.release() } catch (e: Exception) { LogManager.addLog("WARN", "call", "video decoder release: ${e.message}") } decoder = null; format = null; pictures.clear(); inputs.clear() + awaitingPts = -1; presentations.clear() draining = false; endSeen = false; lastOutput = -1 } try { @@ -57,6 +62,10 @@ internal class CallVideoPlayer(private val id: Long, private val failure: (Strin pending = null; displayed.value = null; CallVideoNative.requestKey(id) LogManager.addLog("INFO", "call", "video output attached=${surface != null} call=$id") } + while (true) { + val consumed = presentations.poll() ?: break + if (consumed.surface === surface && consumed.pts == awaitingPts) { awaitingPts = -1; worked = true } + } // Малый output lookahead; основной долг остаётся в encoded FIFO. while (decoder != null && pictures.size < 2 && !endSeen) { val out = decoder!!.dequeueOutputBuffer(info, 0) @@ -80,7 +89,8 @@ internal class CallVideoPlayer(private val id: Long, private val failure: (Strin } val now = System.nanoTime() val valid = NativeLib.callAudioClock(id, now / 1000, clock) && clock[5] != 0L - val first = pictures.peekFirst(); val nextPicture = if (pictures.size > 1) pictures.peekLast() else null + val first = if (awaitingPts < 0) pictures.peekFirst() else null + val nextPicture = if (first != null && pictures.size > 1) pictures.peekLast() else null val decision = CallVideoNative.playoutStep(playout, first?.timing?.pts ?: -1, first?.timing?.received ?: 0, nextPicture?.timing?.pts ?: -1, nextPicture?.timing?.received ?: 0, now / 1000) if (decision > 0) wakeUs = now / 1000 + decision @@ -88,13 +98,14 @@ internal class CallVideoPlayer(private val id: Long, private val failure: (Strin val render = decision == 0L decoder!!.releaseOutputBuffer(first.index, render); pictures.removeFirst(); worked = true if (render) { + awaitingPts = first.timing.pts rendered++ if (displayed.value != format) LogManager.addLog("INFO", "call", "video visible call=$id format=$format") displayed.value = format; lastRendered = now } else late++ lastProgress = now } - if (endSeen && pictures.isEmpty()) { + if (endSeen && pictures.isEmpty() && awaitingPts < 0) { check(inputs.isEmpty()) { "Video EOS before all output frames" } LogManager.addLog("DEBUG", "call", "video decoder drained call=$id generation=${format?.generation}") close() @@ -137,16 +148,18 @@ internal class CallVideoPlayer(private val id: Long, private val failure: (Strin } } } else if (data != null && surface == null) { pending = null; CallVideoNative.requestKey(id) } - if (decoder != null && pictures.isEmpty() && inputs.isNotEmpty() && now - lastProgress > 2_000_000_000) + if (decoder != null && awaitingPts < 0 && pictures.isEmpty() && inputs.isNotEmpty() + && now - lastProgress > 2_000_000_000) error("Video decoder stalled") - if (lastRendered != 0L && now - lastRendered >= 5_000_000_000) { + if (awaitingPts < 0 && lastRendered != 0L && now - lastRendered >= 5_000_000_000) { displayed.value = null; lastRendered = 0 LogManager.addLog("INFO", "call", "video hidden: no rendered frames for 5s call=$id") } if (now - reportAt >= 1_000_000_000) { LogManager.addLog("DEBUG", "call", "video playback call=$id intervalMs=${(now - reportAt) / 1_000_000} " + "pulled=$pulled queued=$queued decoded=$decoded rendered=$rendered skipped=$late inflight=${inputs.size} " + - "pending=${pending != null} ready=${pictures.size} audioValid=$valid audioPts=${clock[4]}") + "pending=${pending != null} ready=${pictures.size} awaitingSurfacePts=$awaitingPts " + + "surfaceWaitMs=${if (awaitingPts >= 0) (now - lastRendered) / 1_000_000 else 0} audioValid=$valid audioPts=${clock[4]}") reportAt = now; pulled = 0; queued = 0; decoded = 0; rendered = 0; late = 0 } } catch (e: Exception) { diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/CallVideoPanel.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/CallVideoPanel.kt index 83efb178..524636f2 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/CallVideoPanel.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/CallVideoPanel.kt @@ -122,7 +122,9 @@ private class CallVideoTexture(context: Context, private val video: CallVideoEng } override fun onSurfaceTextureSizeChanged(texture: SurfaceTexture, width: Int, height: Int) { fit() } override fun onSurfaceTextureDestroyed(texture: SurfaceTexture): Boolean { closeOutput(); return false } - override fun onSurfaceTextureUpdated(texture: SurfaceTexture) {} + override fun onSurfaceTextureUpdated(texture: SurfaceTexture) { + attached?.let { video.presented(it, texture.timestamp / 1000) } + } } } private fun fit() {