Browse Source

Keep preview layout stable and document codec configuration exchange

master
evgeny 7 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 8. Live media и Android
Транспортный wire v3 не меняется. stream_publish_block публикует одну непустую FEC-пачку Транспортный wire v5 не меняется. stream_publish_block публикует одну непустую FEC-пачку
немедленно, с сохранением границ. Первая запись выбирает режим источника: byte write или block write; немедленно, с сохранением границ. Первая запись выбирает режим источника: byte write или block write;
смешивание отвергается до изменения данных. stream_publish_finish не освобождает буфер источника. смешивание отвергается до изменения данных. stream_publish_finish не освобождает буфер источника.
Медиаблок uAV1 находится целиком внутри входа RS, поэтому header восстанавливается вместе с данными. Медиаблок uAV2 находится целиком внутри входа RS, поэтому header восстанавливается вместе с данными.
При штатных 6+2 полный медиаблок <=7345 байт. Раскладка big-endian, без packed-структур: При штатных 6+2 полный медиаблок <=7345 байт. Раскладка big-endian, без packed-структур:
fixed header (32 байта): 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, width:2, height:2, rotation:2, header_size:2, video_size:4, audio_size:4,
first_video_frame_offset:4, config_revision:4 (ненулевой, возрастает при пересоздании видеозахвата). first_video_frame_offset:4, config_revision:4 (ненулевой, возрастает при пересоздании видеозахвата).
Далее video_count описаний по 32 байта, audio_count описаний по 16 байт, Далее video_count описаний по 32 байта, audio_count описаний по 16 байт,
@ -758,13 +758,18 @@ first_video_frame_offset относительно начала video bytes: UINT
иначе это смещение первого фрагмента с fragment_offset=0. Начал может быть несколько, иначе это смещение первого фрагмента с fragment_offset=0. Начал может быть несколько,
таблица описывает каждое. Данные конфигурации не являются отдельным кадром. таблица описывает каждое. Данные конфигурации не являются отдельным кадром.
KEY=bit0 video flags; остальные биты запрещены. frame_id>0, <=16 описаний каждого трека. KEY=bit0 video flags; остальные биты запрещены. frame_id>0, <=16 описаний каждого трека.
Один H.264 кадр <=2MiB. Общий формат допускает медиаблок <=64KiB; реальный максимум дополнительно Один полный видеокадр с конфигурацией <=2MiB. Общий формат допускает медиаблок <=64KiB; реальный максимум дополнительно
ограничен входом RS. Аудиокодек фиксирован на source_epoch. Разрешение, поворот и SPS/PPS видео меняются по config_revision. ограничен входом RS. Аудиокодек фиксирован на source_epoch. Кодек, разрешение, поворот и конфигурация видео меняются по config_revision.
Новая revision может пропускать номера; старая игнорируется. Изменение metadata без новой revision запрещено. Новая revision может пропускать номера; старая игнорируется. Изменение metadata без новой revision запрещено.
При переходе receiver удаляет неполный видеокадр и ждёт полный KEY, сохраняя историю PTS/кадров и аудио. При переходе receiver удаляет неполный видеокадр и ждёт полный KEY, сохраняя историю PTS/кадров и аудио.
video_codec=1: AVC/H.264 Annex B, без B-кадров. Каждый KEY содержит SPS, PPS и IDR в одном video_codec: 1=H.264, 2=H.265, 3=VP9, 4=AV1, 5=H.266/VVC. B-кадры при захвате запрещены.
логическом кадре; это позволяет позднему viewer настроить декодер после получения полного KEY. 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. audio_codec=0: нет аудио; audio_codec=1: Opus mono, audio_codec=2: Opus stereo.
Оба формата: 48000Hz, 960 кадров на канал (20ms), packet<=1275 bytes; interleaved stereo содержит 1920 отсчётов PCM. Оба формата: 48000Hz, 960 кадров на канал (20ms), packet<=1275 bytes; interleaved stereo содержит 1920 отсчётов PCM.
Opus-пакеты между медиаблоками не разрезаются. Размеры треков не включают header/описания. Opus-пакеты между медиаблоками не разрезаются. Размеры треков не включают header/описания.
@ -772,18 +777,23 @@ Opus-пакеты между медиаблоками не разрезаютс
stream_media проверяет весь блок до изменения view. Receiver собирает один последовательный кадр. stream_media проверяет весь блок до изменения view. Receiver собирает один последовательный кадр.
При GAP, потере продолжения или конфликте metadata недособранный кадр удаляется, видео ждёт KEY. При GAP, потере продолжения или конфликте metadata недособранный кадр удаляется, видео ждёт KEY.
Аудио обрабатывается независимо; неверный формат/длины не передаются платформенным декодерам. Аудио обрабатывается независимо; неверный формат/длины не передаются платформенным декодерам.
Ретрансляторы не разбирают uAV1 и не пересоздают подписи источника. Ретрансляторы не разбирают uAV2 и не пересоздают подписи источника.
Android: Android:
- Один foreground publisher на процесс, один viewer. Каждая операция имеет новый session ID. - Один foreground publisher на процесс, один viewer. Каждая операция имеет новый session ID.
- StreamService владеет камерой, микрофоном и PARTIAL_WAKE_LOCK; пересоздание Activity не завершает источник. - StreamService владеет камерой, микрофоном и PARTIAL_WAKE_LOCK; пересоздание Activity не завершает источник.
Сервис camera|microphone запускается из видимого приложения после runtime-разрешений; без микрофона — camera. Сервис camera|microphone запускается из видимого приложения после runtime-разрешений; без микрофона — camera.
- Camera2 TEMPLATE_RECORD пишет в Surface AVC encoder. Режимы камеры <=1920x1080 пересекаются с возможностями - Camera2 TEMPLATE_RECORD пишет в Surface encoder. Кодеки перечисляются в порядке H.264/H.265/VP9/AV1/H.266;
конкретного AVC baseline Surface encoder. Обычные 30/60fps требуют диапазона AE и подходящей minFrameDuration; видны только поддержанные encoder/profile/Surface/size/FPS сочетания, аппаратные encoder имеют приоритет.
Режимы камеры <=1920x1080 пересекаются с возможностями выбранного encoder. Обычные 30/60fps требуют AE и minFrameDuration;
скоростные 120/240fps требуют фиксированного диапазона getHighSpeedVideoFpsRangesFor, скоростной сессии, скоростные 120/240fps требуют фиксированного диапазона getHighSpeedVideoFpsRangesFor, скоростной сессии,
createHighSpeedRequestList и setRepeatingBurst. Битрейт зависит от размера/FPS, ограничен 16Mbit/s и codec caps. createHighSpeedRequestList и setRepeatingBurst. Битрейт зависит от размера/FPS, ограничен 16Mbit/s и codec caps.
I-frame interval=1s. Выход нормализуется в Annex B; I-frame interval=1s. Выход нормализуется в Annex B;
partial output собирается в access unit. Codec config передаётся с каждым IDR. 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 приводятся к общему - Camera MediaCodec Surface timestamps и AudioTimestamp.TIMEBASE_MONOTONIC приводятся к общему
origin System.nanoTime; время callback/получения блока не заменяет capture PTS. origin System.nanoTime; время callback/получения блока не заменяет capture PTS.
- Микрофон трансляции использует AudioRecord.CAMCORDER, 48kHz stereo. Перед Opus AUDIO/128kbps - Микрофон трансляции использует AudioRecord.CAMCORDER, 48kHz stereo. Перед Opus AUDIO/128kbps
@ -838,7 +848,7 @@ Android:
Скоростной режим подтверждается после 3s фактического encode rate >=80% целевой FPS; до этого lastGood не заменяется. Скоростной режим подтверждается после 3s фактического encode rate >=80% целевой FPS; до этого lastGood не заменяется.
При ошибке — одна попытка восстановления последнего успешного режима. При ошибке — одна попытка восстановления последнего успешного режима.
Новая конфигурация и первый KEY принимаются native bridge атомарно; старая очередь видео удаляется, аудио сохраняется. Новая конфигурация и первый 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 не сбрасывается. Kotlin и desktop viewer пересоздают только видеодекодер при смене revision; audio clock не сбрасывается.
- Stop сначала закрывает capture producers и encoder, затем FIN. - Stop сначала закрывает capture producers и encoder, затем FIN.
Publisher сохраняет конечный буфер ещё 2s для сетевой выдачи, затем снимает своё владение. Publisher сохраняет конечный буфер ещё 2s для сетевой выдачи, затем снимает своё владение.
@ -876,7 +886,7 @@ Desktop:
- transport/stream_bridge владеет одним viewer, готовит до двух media records и ограничивает RX 4MiB/16 записей. - transport/stream_bridge владеет одним viewer, готовит до двух media records и ограничивает RX 4MiB/16 записей.
Attach/detach выполняются в uasync; команды и копирование медиаданных защищены mutex. Attach/detach выполняются в uasync; команды и копирование медиаданных защищены mutex.
Post отменяется до destroy ядра; GUI/decoder не хранят заимствованные instance/handle pointers. 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. Android AudioTrack и desktop output работают в stereo; mono-пакеты upmix выполняет libopus.
FFmpeg/Opus декодируются отдельным worker; Qt-очередь не накапливает кадры. FFmpeg/Opus декодируются отдельным worker; Qt-очередь не накапливает кадры.
Worker запрашивает видео при наличии менее двух готовых изображений; защитный предел — четыре. Worker запрашивает видео при наличии менее двух готовых изображений; защитный предел — четыре.
@ -903,7 +913,7 @@ test_stream_service также проверяет общий каталог дв
stream_media_store хранит только диапазоны seq, PTS готового видео/аудио и позиции KEY. stream_media_store хранит только диапазоны seq, PTS готового видео/аудио и позиции KEY.
Индекс ограничен геометрией кольца и 4MiB метаданных; cursor принадлежит отдельному handle. Индекс ограничен геометрией кольца и 4MiB метаданных; cursor принадлежит отдельному handle.
Live ingest cursor и оптимизатор выдач продолжают продвигаться, независимо от паузы player. Live ingest cursor и оптимизатор выдач продолжают продвигаться, независимо от паузы player.
Рабочая память reader — один H.264 кадр до 2MiB, один Opus-пакет и два scratch медиаблока. Рабочая память reader — один видеокадр до 2MiB, один Opus-пакет и два scratch медиаблока.
- Подпись пачки проверяется при первом восстановлении. Индексы использованных RS-частей фиксируются; - Подпись пачки проверяется при первом восстановлении. Индексы использованных RS-частей фиксируются;
повторное чтение использует тот же неизменяемый набор, поэтому поздние части не меняют проверенные данные. повторное чтение использует тот же неизменяемый набор, поэтому поздние части не меняют проверенные данные.
Reader не закрепляет старые части против вытеснения. После выхода за окно кольца он начинает с нового KEY. 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 var problem: String? = null
try { try {
if (info.size > 0) { 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) buffer.position(info.offset); buffer.limit(info.offset + info.size)
val bytes = ByteArray(info.size).also { buffer.get(it) } val bytes = ByteArray(info.size).also { buffer.get(it) }
if (info.flags and MediaCodec.BUFFER_FLAG_CODEC_CONFIG != 0) { 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) { private fun encoded(bytes: ByteArray, info: MediaCodec.BufferInfo, config: StreamCaptureConfig, rotation: Int, revision: Long) {
val mode = config.mode val mode = config.mode
if (partial.size() == 0) { partialPts = info.presentationTimeUs; partialKey = false } if (partial.size() == 0) { partialPts = info.presentationTimeUs; partialKey = false }
check(info.presentationTimeUs == partialPts) { "Partial AVC frame PTS changed" } check(info.presentationTimeUs == partialPts) { "Partial video frame PTS changed" }
check(partial.size() + bytes.size <= H264AnnexB.MAX_FRAME) { "AVC frame exceeds limit" } 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 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 if (info.flags and MediaCodec.BUFFER_FLAG_PARTIAL_FRAME != 0) return
val data = mode.videoCodec.normalize(partial.toByteArray()); partial.reset() val data = mode.videoCodec.normalize(partial.toByteArray()); partial.reset()
val key = partialKey val key = partialKey
val pts = partialPts - originNs / 1000 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++ lastPts = pts; encodedFrames++
if (requireKey && !key) { dropped++; requestKey() } if (requireKey && !key) { dropped++; requestKey() }
else { else {
@ -329,7 +329,7 @@ class StreamCaptureEngine(
if (now - lastKeyRequest < 500_000_000) return if (now - lastKeyRequest < 500_000_000) return
lastKeyRequest = now lastKeyRequest = now
encoder?.setParameters(Bundle().apply { putInt(MediaCodec.PARAMETER_KEY_REQUEST_SYNC_FRAME, 0) }) 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 уже отсечены. */ /** Новая камера открывается только после 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 val s = snapshot?.takeIf { it.id == id } ?: return
Column { Column {
Text(stringResource(R.string.stream_tx_queues, s.videoBytes / 1024.0, s.inflightBytes / 1024.0), 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), 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