Browse Source

Keep preview layout stable and document codec configuration exchange

master
evgeny 5 days ago
parent
commit
b4aeb1a3fd
  1. 36
      doc/stream.txt
  2. 10
      tools/chatgui-android/app/src/main/java/com/utun/chat/data/StreamCaptureEngine.kt
  3. 4
      tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/StreamScreen.kt

36
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.

10
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 уже отсечены. */

4
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)
}
}

Loading…
Cancel
Save