Назад к блогу
Architecture15 min read

Screen Mirror: архитектура низкозадержкового транслирования

В этой статье рассматривается сквозная архитектура системы Screen Mirror в PlainApp: как Android захватывает и аппаратно кодирует H.264/Opus через MediaCodec, как кадры передаются по WebSocket с использованием кастомного бинарного протокола, как веб-сторона декодирует через WebCodecs и рендерит через WebGL2 без копирования через CPU, а также обработка потери пакетов, смена ориентации и удалённое управление касаниями.

Table of Contents

High-Level Architecture

Screen Mirror в PlainApp — это сквозная система транслирования с низкой задержкой: устройство на Android захватывает содержимое экрана, аппаратно кодирует его в H.264 видео и Opus аудио, и отправляет по WebSocket через кастомный бинарный протокол веб-клиенту; веб-клиент декодирует через WebCodecs API и рендерит напрямую в Canvas через WebGL2, без единого копирования через CPU. Прозрачный оверлей касаний замыкает цикл, превращая указательные события обратно в жесты на телефоне.

Никакого WebRTC, никакого RTMP, никакого промежуточного сервера. Весь конвейер выглядит так:

Android VirtualDisplay → MediaCodec H.264 Encoder → WebSocket →
WebCodecs VideoDecoder → WebGL2 Texture → Canvas

Почему не WebRTC?

WebRTC создан для реального времени связи. Его ICE/STUN/TURN согласование, контроль перегрузки и джиттер-буферизация избыточны для LAN-транслирования. Сценарий использования PlainApp таков:

  • Одна локальная сеть, задержка < 5ms, NAT не требуется
  • Стремление к экстремально низкой задержке, без джиттер-буферизации
  • Высокое качество, битрейт может быть высоким (8 Мбит/с)
  • Управление экраном (внедрение касаний), где DataChannel от WebRTC добавляет ненужную сложность

Кастомный бинарный протокол поверх WebSocket легче и более контролируем для сценария локальной сети.

Карта компонентов

Diagram 1
1

СлойAndroidWeb
Захват экранаMediaProjection + VirtualDisplay
Кодирование видеоMediaCodec H.264 аппаратный кодировщик
Кодирование аудиоMediaCodec Opus аппаратный кодировщик
ТранспортWebSocket бинарные событияWebSocket приёмник
Декодирование видеоWebCodecs VideoDecoder
Декодирование аудиоWebCodecs AudioDecoder<audio>
РендерингWebGL2 прямая отрисовка текстуры
УправлениеAccessibilityService внедрение жестовОверлей касаний → GraphQL мутация

Видео и аудио всегда передаются от устройства к браузеру по одному и тому же WebSocket-соединению; управление идёт в обратном направлении через GraphQL (sendScreenMirrorControl), который также служит побочным каналом для конфигурации кодека (screenMirrorVideoCodec запрос) и запросов ключевых кадров (requestScreenMirrorKeyFrame мутация).

Video Encoding Pipeline (Android)

Настройка параметров кодирования

Параметры кодирования были настроены специально для низкозадержкового LAN-транслирования экрана:

ПараметрЗначениеПримечания
KEY_FRAME_RATE6060fps для плавности
KEY_I_FRAME_INTERVAL10Интервал IDR 10 с, снижает накладные расходы на ключевые кадры
KEY_BIT_RATE_MODEVBR (неявно, без явного режима)Переменный битрейт, адаптация к сцене
KEY_PRIORITY0Приоритет реального времени
KEY_LATENCY1Режим низкой задержки

Битрейт распределяется по уровням качества — более высокие битрейты (например, 24 Мбит/с) тестировались, но приводили к сбросу кадров кодировщиком/декодером и увеличению сквозной задержки без заметного прироста качества для экранного контента:

РежимБитрейтРазрешение захвата
HD8 Мбит/с1080p по короткой стороне
Smooth4 Мбит/с1080p по короткой стороне
Low2 Мбит/с720p по короткой стороне

Конфигурация низкой задержки кодировщика

MediaCodecVideoEncoder настраивает кодировщик один раз при создании:

MediaFormat.createVideoFormat(MIME, width, height).apply {
    setInteger(MediaFormat.KEY_COLOR_FORMAT, MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface)
    setInteger(MediaFormat.KEY_BIT_RATE, bitrateBps)
    setInteger(MediaFormat.KEY_FRAME_RATE, frameRate)          // 60
    setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, iFrameIntervalSec) // 10
    setLong(MediaFormat.KEY_REPEAT_PREVIOUS_FRAME_AFTER, 100_000L)
    setInteger(MediaFormat.KEY_COLOR_RANGE, MediaFormat.COLOR_RANGE_LIMITED)
    setInteger(MediaFormat.KEY_PRIORITY, 0)
    setInteger(MediaFormat.KEY_LATENCY, 1)
}

KEY_PRIORITY=0 и KEY_LATENCY=1 — ключи к низкой задержке: они сообщают кодировщику, что нужно приоритезировать кодирование в реальном времени над степенью сжатия. Входом является Surface, созданный MediaCodec.createInputSurface() и передаваемый напрямую в VirtualDisplay — никакого чтения SurfaceTexture, никакого преобразования в I420, CPU не касается пикселей.

Разрешение захвата

ScreenMirrorCaptureSize.compute() вычисляет фактический размер захвата на основе физического размера экрана, целевой короткой стороны для режима качества (720/1080) и сообщённых кодировщиком maxWidth/maxHeight и выравнивания ширины/высоты (запрашивается один раз через MediaCodecVideoEncoder.queryEncoderCaps()), так что кодировщик никогда не получает размеры, которые не может принять.

Запросы ключевых кадров

Веб-клиент может запросить IDR-кадр через GraphQL мутацию requestScreenMirrorKeyFrame для восстановления после потери пакетов. Android отвечает через MediaCodec.PARAMETER_KEY_REQUEST_SYNC_FRAME:

fun requestKeyFrame() {
    val b = Bundle().apply { putInt(MediaCodec.PARAMETER_KEY_REQUEST_SYNC_FRAME, 1) }
    codec?.setParameters(b)
}

SPS/PPS и рассылка ключевых кадров

После запуска кодировщика INFO_OUTPUT_FORMAT_CHANGED доставляет csd-0/csd-1 (SPS/PPS), которые ScreenMirrorPipeline объединяет в единый Annex-B конфигурационный блок и кэширует (cachedConfig). Первый следующий за ним IDR также кэшируется (cachedKeyFrame), чтобы только что подключившийся веб-клиент мог получить оба через GraphQL-запрос screenMirrorVideoCodec, не дожидаясь следующего интервала ключевых кадров. Когда конфигурация только что изменилась (смена ориентации или качества), Android не отправляет новый IDR как обычный видеопакет — он упаковывает SPS/PPS + IDR в одно WebSocket-событие screen_mirror_video_codec, чтобы веб-клиент выполнил перенастройку декодера и декодирование первого кадра за один раз, вместо того чтобы гонять устаревший декодер против нового битового потока.

Некоторые OEM-кодировщики (Qualcomm/Xiaomi) упаковывают SPS+PPS+IDR в один выходной буфер, несущий одновременно BUFFER_FLAG_CODEC_CONFIG и BUFFER_FLAG_SYNC_FRAME. Цикл извлечения пропускает только буферы, которые являются чисто конфигурационными (isConfig && !isKey) — пропуск буфера с флагом конфигурации, который также несёт синхрокадр, приведёт к молчаливому сбросу IDR, и декодер останется только с P-кадрами, порождая мозаичный вывод.

VideoPacket Protocol Design

Как видео-, так и аудиокадры оборачиваются в единый бинарный протокол VideoPacket для транспортировки по WebSocket.

Формат протокола

+--------+--------+--------+--------+--------+--------+--------+--------+--------+--------+--------+--------+--------+--------+--------+
| MAGIC  | FLAGS  |           FRAME_ID (4 bytes, big-endian)            |              TIMESTAMP (8 bytes, BE)              |  DATA  |
| 0x56   |        |   byte2   |   byte3   |   byte4   |   byte5   |  byte6  |  byte7  | ...  |  byte13 |        payload...        |
+--------+--------+--------+--------+--------+--------+--------+--------+--------+--------+--------+--------+--------+--------+--------+
 \- 1B -/ \- 1B -/ \-------------------- 4B ----------------------/ \----------------------- 8B ------------------------/ \- var -/
ПолеРазмерОписание
MAGIC1 байтФиксированное 0x56 ('V'), для валидации
FLAGS1 байт0x01=ключевой кадр, 0x02=конфигурация, 0x04=аудио
FRAME_ID4 байтаМонотонно возрастающий номер кадра, uint32 big-endian
TIMESTAMP8 байтPTS от кодировщика в микросекундах, big-endian
DATAпеременнаяH.264 NAL-единица или Opus-данные

И VideoPacket.encode() на Android (в commonMain, так что его формат передачи покрывается JVM-юнит-тестами без какой-либо зависимости от Android), и parseVideoPacket() на вебе реализуют этот формат независимо — нет общей библиотеки сериализации, только спецификация, которой обе стороны следуют.

Diagram 2
2

Замечания по дизайну

  • Беззнаковый парсинг FRAME_ID: ((buf[2] << 24) | (buf[3] << 16) | (buf[4] << 8) | buf[5]) >>> 0 — необходимо использовать >>> 0 для обеспечения беззнаковости, иначе frameId > 2^31 будет распознан как отрицательное число, вызывая ложное обнаружение потери.
  • FRAME_ID никогда не сбрасывается: при пересоздании кодировщика из-за смены ориентации frameId продолжает увеличиваться (он живёт в ScreenMirrorPipeline, а не в кодировщике). Это позволяет веб-стороне обнаруживать потерю кадров во время поворота по пробелам в frameId.
  • TIMESTAMP использует PTS от кодировщика: нет зависимости от часов веб-клиента, что исключает рассинхронизацию A/V из-за дрейфа часов.
  • Zero-copy парсинг: веб-парсер выделяет полезную нагрузку через Uint8Array.subarray() — это представление исходного ArrayBuffer WebSocket, а не копия.

Video Decoding Pipeline (Web)

WebCodecs VideoDecoder

Веб-клиент использует VideoDecoder из WebCodecs API для аппаратного декодирования. По сравнению с MediaSource Extensions или WebRTC, WebCodecs предоставляет тонкий контроль над процессом декодирования — без джиттер-буфера, без контейнерного слоя, а декодированные объекты VideoFrame могут быть напрямую загружены как текстуры WebGL.

const decoder = new VideoDecoder({
    output: (frame) => this.renderFrame(frame),
    error: (e) => {
        this.waitingForIdr = true
        this.onRequestKeyFrame?.()
        this.onError?.(e)
    },
})
decoder.configure({
    codec,                              // например 'avc1.42c01e', читается из SPS NAL
    avc: { format: 'annexb' },
    optimizeForLatency: true,
    hardwareAcceleration: 'prefer-hardware',
})

Ключевые конфигурации:

  • optimizeForLatency: true — сообщает декодеру приоритезировать низкую задержку, без буферизации кадров
  • hardwareAcceleration: 'prefer-hardware' — предпочитать GPU-декодирование
  • avc: { format: 'annexb' } — использовать формат Annex-B с встроенными SPS/PPS перед каждым IDR
  • строка кодека не захардкожена — extractAvc1CodecString() читает байты profile/compat/level непосредственно из первого SPS NAL в конфигурационном блоке

Проблема зелёного экрана и последовательность запуска

Кодировщик производит свой первый IDR-кадр до того, как VirtualDisplay отрендерит реальное содержимое экрана — это пустой (зелёный) кадр. Если веб-сторона декодирует этот кадр, пользователь увидит зелёную вспышку, пока содержимое экрана не изменится и не вызовет новый кадр.

Решение: При запуске веб-сторона извлекает кэшированную конфигурацию через GraphQL-запрос screenMirrorVideoCodec, но не декодирует вложенный ключевой кадр. Вместо этого она вызывает video.requestIdr(), чтобы установить waitingForIdr = true (сбрасывая все P-кадры до прибытия IDR), а затем вызывает requestKeyFrame() для запроса свежего IDR через ту же мутацию, что используется для восстановления после потерь. К моменту прибытия нового IDR VirtualDisplay уже содержит реальное содержимое экрана.

video.requestIdr()      // сброс P-кадров, ожидание IDR
await requestKeyFrame() // запрос свежего IDR через GraphQL мутацию

Колбэк onFirstFrameRendered привязан к renderFrame(), а не к handleVideo(), что гарантирует обновление UI только после отрисовки реального кадра, а не просто его получения.

WebGL2 Rendering

Zero-Copy GPU Direct Render

Декодированные объекты VideoFrame напрямую загружаются как текстуры WebGL2, никогда не проходя через CPU:

VideoDecoder → VideoFrame → gl.texImage2D(VideoFrame) → Canvas

gl.texImage2D принимает VideoFrame как источник пикселей. Браузер обрабатывает преобразование YUV→RGB и загрузку в GPU внутри — никакой копии ImageData через CPU. MirrorGLRenderer откатывается к drawImage() Canvas 2D, если getContext('webgl2', ...) не удаётся, так что старые браузеры всё ещё получают изображение (с немного большей задержкой).

desynchronized Контекст

const gl = canvas.getContext('webgl2', {
    alpha: false,
    desynchronized: true,        // обход композитора, прямая запись на экран
    preserveDrawingBuffer: true, // сохранение буфера для скриншотов
    powerPreference: 'high-performance',
    antialias: false,
    depth: false,
    stencil: false,
    premultipliedAlpha: false,
})

desynchronized: true обходит композитор браузера, записывая напрямую на экран, экономя примерно 1 кадр задержки отображения (~16ms при 60fps).

preserveDrawingBuffer: true сохраняет буфер отрисовки, чтобы скриншоты через canvas.toDataURL() могли читать содержимое. При значении по умолчанию false буфер очищается после композитинга, что даёт чёрные скриншоты.

Сам шейдер намеренно минимален — вершинный шейдер полноэкранного треугольника и однострочный фрагментный шейдер, семплирующий текстуру, — потому что единственная работа, необходимая для каждого кадра, это "поместить эту текстуру на экран".

Canvas Auto-Fit

Размер backing store у canvas устанавливается из VideoFrame.displayWidth/Height при каждом изменении. CSS-размер затем подгоняется под контейнер-обёртку с помощью fitCanvasToWrapper() с сохранением соотношения сторон (letterboxing или pillarboxing по необходимости). ResizeObserver на родительском элементе canvas повторно запускает эту подгонку при каждом изменении размера контейнера, так что видео никогда не растягивается.

Loss Detection & Error Recovery

FrameId Gap Detection

Каждый видеокадр несёт монотонно возрастающий frameId. Декодер отслеживает lastFrameId; если frameId нового кадра > lastFrameId + 1, кадры были потеряны:

if (!this.waitingForIdr && this.lastFrameId > 0
    && packet.frameId > this.lastFrameId + 1) {
    if (!packet.isKeyFrame) {
        // Потеря: сброс последующих P-кадров, запрос нового IDR
        this.waitingForIdr = true
        this.onRequestKeyFrame?.()
        this.lastFrameId = packet.frameId
        return
    }
}

waitingForIdr State Machine

waitingForIdr — это простой автомат с двумя состояниями:

Diagram 3
3

СостояниеПоведение
NORMALДекодировать все кадры в обычном режиме
WAITING_FOR_IDRСбрасывать все P-кадры, декодировать только IDR; сброс в NORMAL по прибытии IDR

Сценарии, инициирующие переход в WAITING_FOR_IDR:

  1. При запуске: пропуск устаревшего GraphQL-ключа, ожидание реального IDR
  2. При потере пакетов: сброс недекодируемых P-кадров, ожидание восстановления по IDR
  3. При ошибке декодера: сброс декодера, ожидание IDR
  4. При изменении конфигурации: сброс остаточных P-кадров после смены ориентации/качества

Decoder Error Recovery

Когда срабатывает VideoDecoder.onerror, в слое конвейера (screen-mirror-pipeline.ts) устанавливается decoderNeedsReset = true. При следующем IDR-кадре декодер перенастраивается с кэшированными SPS/PPS вместо повторного обхода через GraphQL:

if (decoderNeedsReset) {
    if (!packet.isKeyFrame || !cachedConfig) return
    video.configure(cachedConfig)
    decoderNeedsReset = false
}

Backpressure и дедупликация временных меток

Если decoder.decodeQueueSize > 5, входящие P-кадры сбрасываются, а не ставятся в очередь — порог 5 (а не 2) допускает задержку запуска аппаратного декодера без излишних заиканий. Отдельно, после рендеринга записывается lastRenderedPts; кадр с отметкой времени старше (неупорядоченное прибытие) сбрасывается, если только это не ключевой кадр:

if (packet.timestamp < this.lastRenderedPts && !packet.isKeyFrame) {
    return
}

Orientation Change Handling

Encoder Rebuild

OrientationEventListener в ScreenMirrorService сравнивает rotation дисплея с кэшированным флагом isPortrait при каждом срабатывании сенсора; только настоящий переворот портрет/ландшафт вызывает pipeline.onOrientationChanged() и инвалидирует кэш размера экрана Accessibility, используемый для масштабирования координат касаний.

Diagram 4
4

rebuildEncoderAndResize():

  1. Создать новый кодировщик с новыми размерами (например, ландшафт 1920x1080)
  2. Переключить VirtualDisplay.surface на входной Surface нового кодировщика
  3. Остановить старый кодировщик
  4. VirtualDisplay.resize() на новые размеры

Переключение Surface происходит до изменения размера — гарантируя, что новый кодировщик получает кадры первым, а старый кодировщик останавливается до того, как может получить кадры неверного размера. Если virtualDisplay?.surface = ... выбрасывает исключение, перестроение прерывается и старый кодировщик остаётся работать, чтобы не оставить конвейер без кодировщика вовсе.

Config Change Notification

Когда новый кодировщик впервые выводит SPS/PPS, на конвейере устанавливается pendingConfigBroadcast. Когда прибывает первый IDR от нового кодировщика, он упаковывается вместе с этой конфигурацией в одно событие screen_mirror_video_codec вместо отправки как обычного видеопакета.

Затем веб-клиент в handleConfig():

  1. Перенастраивает декодер с новыми SPS/PPS
  2. Немедленно декодирует упакованный IDR-кадр
  3. Вызывает video.requestIdr() для сброса любых остаточных P-кадров от старого кодировщика, всё ещё находящихся в пути
  4. Вызывает requestKeyFrame() для запроса чистого, свежего IDR

Шаги 3-4 — это страховочная сетка: даже если первый IDR нового кодировщика имеет неверные размеры (в окне асинхронного изменения размера), веб-клиент быстро восстанавливается до корректных размеров. handleConfig() также прерывается досрочно, если входящая конфигурация побайтово идентична кэшированной, поскольку перенастройка декодера с неизменёнными байтами — это холостая операция, которая всё равно стоит IDR для восстановления.

System MediaProjection Lifecycle

Проблема

Пользователи могут закрыть системное транслирование экрана (MediaProjection) через панель уведомлений Android, а не через UI приложения. В этом случае ScreenMirrorService не знает, что транслирование остановлено — running остаётся true, веб-клиент запрашивает screenMirrorState и получает true, но видеокадры не приходят, и страница зависает на загрузке.

MediaProjection.Callback

MediaProjection предоставляет колбэк Callback.onStop(), который срабатывает, когда система останавливает транслирование. ScreenMirrorPipeline.startEncoders() регистрирует этот колбэк и вызывает ScreenMirrorService.instance?.stop() в onStop():

projection.registerCallback(object : MediaProjection.Callback() {
    override fun onStop() {
        ScreenMirrorService.instance?.stop()
    }
}, null)

Diagram 5
5

Service.stop() Обязанности

stop() — это явная точка остановки, отвечающая за уведомление веб-клиента и остановку службы:

fun stop() {
    if (!running) return  // предотвращение рекурсии
    running = false
    sendEvent(WebSocketEvent(EventType.SCREEN_MIRRORING, """{"running":false}"""))
    stopForeground(STOP_FOREGROUND_REMOVE)
    stopSelf()
}

Защита if (!running) return предотвращает рекурсию: onStop()stop()stopSelf()onDestroy()pipeline.stop()projection.stop()onStop()stop() (в этот момент running=false, немедленный возврат).

Обработка на веб-стороне

Когда веб-клиент получает событие {"running":false}, он сбрасывается в состояние ожидания и показывает кнопку запуска:

const onScreenMirroring = (data: any) => {
    if (data?.running === false) {
        cleanupFn()
        fullReset()
        return
    }
    // running=true → подключение к потоку
}

Remote Control: Touch Injection

Зеркалирование экрана по умолчанию однонаправленное (только видео/аудио); удалённое управление опционально и требует, чтобы пользователь один раз включил Accessibility Service PlainApp, поскольку у Android нет публичного API для внедрения произвольных событий касаний вне AccessibilityService.dispatchGesture().

Diagram 6
6

Coordinate Normalization (Web)

Прозрачный оверлей располагается поверх <canvas> и перехватывает указательные события. normalizeCoords() преобразует сырые clientX/clientY в координаты [0,1] относительно фактической области видеоконтента — а не ограничивающей рамки оверлея — вычисляя смещение letterbox/pillarbox из соотношения сторон backing store canvas по сравнению с соотношением сторон его контейнера:

if (videoAspect > containerAspect) {
    // Letterbox сверху/снизу
    renderW = containerW
    renderH = containerW / videoAspect
    offsetY = (containerH - renderH) / 2
} else {
    // Pillarbox слева/справа
    renderH = containerH
    renderW = containerH * videoAspect
    offsetX = (containerW - renderW) / 2
}

Нажатие указателя запускает GestureState, который отслеживает начальную позицию/время; удержание 500ms с перемещением < 10px переводится в LONG_PRESS, движение за этот порог становится SWIPE, а быстрое отпускание — TAP. Визуальный индикатор касания (растущая/исчезающая точка) даёт оператору обратную связь о том, какой жест был распознан, ещё до того, как телефон ответит.

GraphQL → AccessibilityService

Каждый распознанный жест отправляется как одна мутация sendScreenMirrorControl(input), несущая action (TAP/LONG_PRESS/SWIPE/SCROLL/BACK/HOME/RECENTS/LOCK_SCREEN/KEY) плюс нормализованные координаты. Резолвер вызывает dispatchScreenMirrorControl(), который умножает нормализованные координаты на реальный размер экрана (из PlainAccessibilityService.getScreenSize(), инвалидируется при каждой смене ориентации) и делегирует PlainAccessibilityService.dispatchControl():

private fun dispatchTap(x: Float, y: Float) {
    val path = Path().apply { moveTo(x, y) }
    val stroke = GestureDescription.StrokeDescription(path, 0, 50)
    dispatchGesture(GestureDescription.Builder().addStroke(stroke).build(), null, null)
}

SWIPE и LONG_PRESS строят тот же GestureDescription с большей длительностью штриха или линейным путём вместо одной точки; SCROLL реализуется как синтетический свайп от (x, y) к (x, y + deltaY), ограниченный ±500px. Четыре глобальных действия (BACK/HOME/RECENTS/LOCK_SCREEN) полностью пропускают диспетчеризацию жестов и вызывают performGlobalAction() напрямую. Если Accessibility Service не включён, резолвер выбрасывает GraphQLError вместо молчаливого сброса ввода, чтобы веб-UI мог предложить пользователю включить его.

Audio Pipeline

Android Opus Encoding

MediaCodecAudioEncoder использует AudioPlaybackCaptureConfiguration (построенный из того же MediaProjection) для захвата системного аудио через AudioRecord, подавая сырой PCM в кодировщик MediaCodec Opus. Это требует Android 10+ и разрешения RECORD_AUDIO — на старых устройствах или без разрешения start() логирует предупреждение и пропускает аудио (видео продолжает работать). Закодированные Opus-пакеты оборачиваются в тот же протокол VideoPacket (с установленным FLAG_AUDIO) и разделяют видеоканальный WebSocket-канал SCREEN_MIRROR_AUDIO.

Web Opus Decoding

ScreenMirrorAudioPipeline использует WebCodecs AudioDecoder для декодирования Opus-данных, выводя AudioData, направляемые в элемент <audio>. Временная метка аудиокадра timestamp используется для A/V синхронизации — используя ту же временную базу (PTS кодировщика, в микросекундах), что и видеокадры, поэтому не требуется отдельного согласования часов между двумя потоками.

Performance Optimizations

Zero-Copy Paths

ПутьМетод
VirtualDisplay → Surface кодировщикаGPU direct, сквозной проход Surface
VideoDecoder → VideoFrame → WebGL текстураgl.texImage2D(VideoFrame), GPU direct
Получение WebSocket → парсинг VideoPacketUint8Array.subarray() — это представление, без копирования

avccToAnnexB Оптимизация

Некоторые Android-кодировщики выводят формат AVCC (префикс длины 4 байта), который требует преобразования в формат Annex-B (стартовый код 00 00 00 01) для декодирования WebCodecs.

Ранняя реализация использовала ArrayList<Byte> с побайтовой упаковкой — IDR-кадр размером 50 КБ порождал 50 000 операций упаковки java.lang.Byte, создавая огромное давление на GC. Оптимизация использует двухпроходное сканирование + copyInto (которое отображается на intrinsic System.arraycopy на JVM):

// Первый проход: вычисление размера вывода
var outSize = 0
// Второй проход: массовое копирование
val out = ByteArray(outSize)
avcc.copyInto(out, writeOff + 4, off + 4, off + 4 + len)

P-Frame Drop Strategy

Декодер может быть медленным во время инициализации. Если очередь P-кадров слишком длинная, задержка накапливается. Порог размера очереди декодирования установлен в > 5 (а не > 2), чтобы избежать чрезмерной потери кадров во время инициализации аппаратного декодера.

IDR Request Deduplication

Защита waitingForIdr гарантирует только один запрос IDR на событие потери, предотвращая дублирующиеся запросы в ожидании прибытия IDR.

Design Patterns Recap

ПаттернГдеЗачем
State MachineФлаг waitingForIdrЯвные переходы состояния сброса/восстановления P-кадров
Recursion Guardif (!running) return в stop()Предотвращает рекурсию onStop → stop → onDestroy → pipeline.stop → projection.stop → onStop
Zero-Copy PipelineVideoFrame → gl.texImage2DПрямая загрузка текстуры GPU, без копирования CPU
Two-Pass ScanavccToAnnexBПредварительное вычисление размера, единое выделение + массовое копирование, устранение упаковки
Bundled EventSPS/PPS + IDR в одном событииИзменение конфигурации завершает перенастройку + декодирование первого кадра за одно событие
Callback SeparationonFirstFrameRendered vs onDisconnected vs onScreenMirrorOffЧёткое различие между рендером первого кадра, отказом транспорта и остановкой на телефоне
Safety NetrequestIdr() + requestKeyFrame()Сброс остаточных кадров + запрос чистого IDR после изменения конфигурации
PTS Deduplicationtimestamp < lastRenderedPtsСброс неупорядоченных кадров
FrameId GapframeId > lastFrameId + 1Обнаружение потери пакетов без ACK
desynchronized ContextWebGL2 desynchronized: trueОбход композитора, экономия 1 кадра задержки
Explicit Fail FastsendScreenMirrorControl выбрасывает GraphQLErrorСообщает "доступность отключена" вместо молчаливого сброса ввода

Further Reading

  • WebCodecs API — документация MDN, описывающая интерфейсы VideoDecoder/AudioDecoder.