From b4aeb1a3fd40a62ea4b7768df491013c0d6f38aa Mon Sep 17 00:00:00 2001 From: evgeny Date: Wed, 14 Oct 2026 01:56:02 +0200 Subject: [PATCH] Keep preview layout stable and document codec configuration exchange --- doc/stream.txt | 36 ++++++++++++------- .../com/utun/chat/data/StreamCaptureEngine.kt | 10 +++--- .../com/utun/chat/ui/screens/StreamScreen.kt | 4 +-- 3 files changed, 30 insertions(+), 20 deletions(-) diff --git a/doc/stream.txt b/doc/stream.txt index 826038b1..6e4d0dab 100644 --- a/doc/stream.txt +++ b/doc/stream.txt @@ -734,15 +734,15 @@ Restart в той же source_epoch сохраняет consumer cursor: bootstra 8. Live media и Android -Транспортный wire v3 не меняется. stream_publish_block публикует одну непустую FEC-пачку +Транспортный wire v5 не меняется. stream_publish_block публикует одну непустую FEC-пачку немедленно, с сохранением границ. Первая запись выбирает режим источника: byte write или block write; смешивание отвергается до изменения данных. stream_publish_finish не освобождает буфер источника. -Медиаблок uAV1 находится целиком внутри входа RS, поэтому header восстанавливается вместе с данными. +Медиаблок uAV2 находится целиком внутри входа RS, поэтому header восстанавливается вместе с данными. При штатных 6+2 полный медиаблок <=7345 байт. Раскладка big-endian, без packed-структур: fixed header (32 байта): - magic="uAV1":4, video_codec:1, audio_codec:1, video_count:1, audio_count:1, + magic="uAV2":4, video_codec:1, audio_codec:1, video_count:1, audio_count:1, width:2, height:2, rotation:2, header_size:2, video_size:4, audio_size:4, first_video_frame_offset:4, config_revision:4 (ненулевой, возрастает при пересоздании видеозахвата). Далее video_count описаний по 32 байта, audio_count описаний по 16 байт, @@ -758,13 +758,18 @@ first_video_frame_offset относительно начала video bytes: UINT иначе это смещение первого фрагмента с fragment_offset=0. Начал может быть несколько, таблица описывает каждое. Данные конфигурации не являются отдельным кадром. KEY=bit0 video flags; остальные биты запрещены. frame_id>0, <=16 описаний каждого трека. -Один H.264 кадр <=2MiB. Общий формат допускает медиаблок <=64KiB; реальный максимум дополнительно -ограничен входом RS. Аудиокодек фиксирован на source_epoch. Разрешение, поворот и SPS/PPS видео меняются по config_revision. +Один полный видеокадр с конфигурацией <=2MiB. Общий формат допускает медиаблок <=64KiB; реальный максимум дополнительно +ограничен входом RS. Аудиокодек фиксирован на source_epoch. Кодек, разрешение, поворот и конфигурация видео меняются по config_revision. Новая revision может пропускать номера; старая игнорируется. Изменение metadata без новой revision запрещено. При переходе receiver удаляет неполный видеокадр и ждёт полный KEY, сохраняя историю PTS/кадров и аудио. -video_codec=1: AVC/H.264 Annex B, без B-кадров. Каждый KEY содержит SPS, PPS и IDR в одном -логическом кадре; это позволяет позднему viewer настроить декодер после получения полного KEY. +video_codec: 1=H.264, 2=H.265, 3=VP9, 4=AV1, 5=H.266/VVC. B-кадры при захвате запрещены. +KEY payload: config_length:4BE, decoder configuration (<=64KiB), access unit (непустой). +Обычный payload содержит только access unit. frame_size/offset учитывают всю оболочку KEY. +Конфигурация повторяется с каждым KEY, защищена подписью/RS и разбирается только после полной сборки кадра. +H.264/H.265/H.266 используют Annex B; VP9/AV1 сохраняют собственные access units и opaque codec configuration. +Для H.264 конфигурация содержит SPS/PPS, для H.265 — VPS/SPS/PPS; самодостаточный KEY может иметь пустую конфигурацию. +Ранняя подписка и возобновление после GAP не требуют отдельного CONFIG-запроса. Старый uAV1 отвергается. audio_codec=0: нет аудио; audio_codec=1: Opus mono, audio_codec=2: Opus stereo. Оба формата: 48000Hz, 960 кадров на канал (20ms), packet<=1275 bytes; interleaved stereo содержит 1920 отсчётов PCM. Opus-пакеты между медиаблоками не разрезаются. Размеры треков не включают header/описания. @@ -772,18 +777,23 @@ Opus-пакеты между медиаблоками не разрезаютс stream_media проверяет весь блок до изменения view. Receiver собирает один последовательный кадр. При GAP, потере продолжения или конфликте metadata недособранный кадр удаляется, видео ждёт KEY. Аудио обрабатывается независимо; неверный формат/длины не передаются платформенным декодерам. -Ретрансляторы не разбирают uAV1 и не пересоздают подписи источника. +Ретрансляторы не разбирают uAV2 и не пересоздают подписи источника. Android: - Один foreground publisher на процесс, один viewer. Каждая операция имеет новый session ID. - StreamService владеет камерой, микрофоном и PARTIAL_WAKE_LOCK; пересоздание Activity не завершает источник. Сервис camera|microphone запускается из видимого приложения после runtime-разрешений; без микрофона — camera. -- Camera2 TEMPLATE_RECORD пишет в Surface AVC encoder. Режимы камеры <=1920x1080 пересекаются с возможностями - конкретного AVC baseline Surface encoder. Обычные 30/60fps требуют диапазона AE и подходящей minFrameDuration; +- Camera2 TEMPLATE_RECORD пишет в Surface encoder. Кодеки перечисляются в порядке H.264/H.265/VP9/AV1/H.266; + видны только поддержанные encoder/profile/Surface/size/FPS сочетания, аппаратные encoder имеют приоритет. + Режимы камеры <=1920x1080 пересекаются с возможностями выбранного encoder. Обычные 30/60fps требуют AE и minFrameDuration; скоростные 120/240fps требуют фиксированного диапазона getHighSpeedVideoFpsRangesFor, скоростной сессии, createHighSpeedRequestList и setRepeatingBurst. Битрейт зависит от размера/FPS, ограничен 16Mbit/s и codec caps. I-frame interval=1s. Выход нормализуется в Annex B; partial output собирается в access unit. Codec config передаётся с каждым IDR. +- Битрейт выбирается отдельно: Авто по разрешению/FPS либо пресет в диапазоне encoder. + На LIVE применяется PARAMETER_KEY_VIDEO_BITRATE; при отказе используется штатная смена конфигурации/rollback. + Смена кодека/камеры/режима создаёт revision и принимается вместе с KEY, без смены source_epoch и перезапуска звука. + Encoder configuration из отдельных NAL callbacks объединяется без дублирования parameter sets. - Camera MediaCodec Surface timestamps и AudioTimestamp.TIMEBASE_MONOTONIC приводятся к общему origin System.nanoTime; время callback/получения блока не заменяет capture PTS. - Микрофон трансляции использует AudioRecord.CAMCORDER, 48kHz stereo. Перед Opus AUDIO/128kbps @@ -838,7 +848,7 @@ Android: Скоростной режим подтверждается после 3s фактического encode rate >=80% целевой FPS; до этого lastGood не заменяется. При ошибке — одна попытка восстановления последнего успешного режима. Новая конфигурация и первый KEY принимаются native bridge атомарно; старая очередь видео удаляется, аудио сохраняется. - Каждая запись JNI pull имеет header 24 bytes: kind:1, flags:1, width:2, height:2, rotation:2, PTS_us:8, length:4, revision:4. + JNI pull header 28 bytes: kind:1, flags:1, width:2, height:2, rotation:2, PTS_us:8, length:4, revision:4, codec:1, reserved:3. Kotlin и desktop viewer пересоздают только видеодекодер при смене revision; audio clock не сбрасывается. - Stop сначала закрывает capture producers и encoder, затем FIN. Publisher сохраняет конечный буфер ещё 2s для сетевой выдачи, затем снимает своё владение. @@ -876,7 +886,7 @@ Desktop: - transport/stream_bridge владеет одним viewer, готовит до двух media records и ограничивает RX 4MiB/16 записей. Attach/detach выполняются в uasync; команды и копирование медиаданных защищены mutex. Post отменяется до destroy ядра; GUI/decoder не хранят заимствованные instance/handle pointers. -- stream_media_cursor собирает ближайший H.264 access unit и выдаёт Opus 48kHz mono/stereo/20ms из RS-кольца. +- stream_media_cursor собирает ближайший access unit выбранного кодека и выдаёт Opus 48kHz mono/stereo/20ms из RS-кольца. Android AudioTrack и desktop output работают в stereo; mono-пакеты upmix выполняет libopus. FFmpeg/Opus декодируются отдельным worker; Qt-очередь не накапливает кадры. Worker запрашивает видео при наличии менее двух готовых изображений; защитный предел — четыре. @@ -903,7 +913,7 @@ test_stream_service также проверяет общий каталог дв stream_media_store хранит только диапазоны seq, PTS готового видео/аудио и позиции KEY. Индекс ограничен геометрией кольца и 4MiB метаданных; cursor принадлежит отдельному handle. Live ingest cursor и оптимизатор выдач продолжают продвигаться, независимо от паузы player. - Рабочая память reader — один H.264 кадр до 2MiB, один Opus-пакет и два scratch медиаблока. + Рабочая память reader — один видеокадр до 2MiB, один Opus-пакет и два scratch медиаблока. - Подпись пачки проверяется при первом восстановлении. Индексы использованных RS-частей фиксируются; повторное чтение использует тот же неизменяемый набор, поэтому поздние части не меняют проверенные данные. Reader не закрепляет старые части против вытеснения. После выхода за окно кольца он начинает с нового KEY. diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/StreamCaptureEngine.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/StreamCaptureEngine.kt index adf05e2a..72a7dd30 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/StreamCaptureEngine.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/StreamCaptureEngine.kt @@ -162,7 +162,7 @@ class StreamCaptureEngine( var problem: String? = null try { if (info.size > 0) { - val buffer = codec.getOutputBuffer(index) ?: error("Missing AVC output") + val buffer = codec.getOutputBuffer(index) ?: error("Missing video encoder 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) { @@ -271,14 +271,14 @@ class StreamCaptureEngine( 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" } + check(info.presentationTimeUs == partialPts) { "Partial video frame PTS changed" } + check(partial.size() + bytes.size <= H264AnnexB.MAX_FRAME) { "Video frame exceeds limit" } partial.write(bytes); partialKey = partialKey || info.flags and MediaCodec.BUFFER_FLAG_KEY_FRAME != 0 if (info.flags and MediaCodec.BUFFER_FLAG_PARTIAL_FRAME != 0) return val data = mode.videoCodec.normalize(partial.toByteArray()); partial.reset() val key = partialKey val pts = partialPts - originNs / 1000 - check(pts >= 0 && pts > lastPts) { "AVC PTS outside timeline pts=$pts last=$lastPts" } + check(pts >= 0 && pts > lastPts) { "Video PTS outside timeline pts=$pts last=$lastPts" } lastPts = pts; encodedFrames++ if (requireKey && !key) { dropped++; requestKey() } else { @@ -329,7 +329,7 @@ class StreamCaptureEngine( 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 revision=$generation") + LogManager.addLog("INFO", "video", "requested video keyframe id=$id encoder=${encoder?.name} revision=$generation") } /** Новая камера открывается только после onClosed; старые codec callbacks уже отсечены. */ diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/StreamScreen.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/StreamScreen.kt index 25a43281..5bf447ef 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/StreamScreen.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/StreamScreen.kt @@ -173,9 +173,9 @@ private fun StreamTxPanel(id: Long) { val s = snapshot?.takeIf { it.id == id } ?: return Column { Text(stringResource(R.string.stream_tx_queues, s.videoBytes / 1024.0, s.inflightBytes / 1024.0), - style = MaterialTheme.typography.bodySmall) + style = MaterialTheme.typography.bodySmall, minLines = 2) Text(stringResource(R.string.stream_tx_total, s.totalBytes / 1024.0, s.acked1s / 1024.0, s.acked1s * 8 / 1_000_000.0), - style = MaterialTheme.typography.bodySmall) + style = MaterialTheme.typography.bodySmall, minLines = 2) } }