Table of Contents
- High-Level Architecture
- Video Encoding Pipeline (Android)
- VideoPacket Protocol Design
- Video Decoding Pipeline (Web)
- WebGL2 Rendering
- Loss Detection & Error Recovery
- Orientation Change Handling
- System MediaProjection Lifecycle
- Remote Control: Touch Injection
- Audio Pipeline
- Performance Optimizations
- Design Patterns Recap
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 легче и более контролируем для сценария локальной сети.
Карта компонентов
| Слой | Android | Web |
|---|---|---|
| Захват экрана | 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_RATE | 60 | 60fps для плавности |
KEY_I_FRAME_INTERVAL | 10 | Интервал IDR 10 с, снижает накладные расходы на ключевые кадры |
KEY_BIT_RATE_MODE | VBR (неявно, без явного режима) | Переменный битрейт, адаптация к сцене |
KEY_PRIORITY | 0 | Приоритет реального времени |
KEY_LATENCY | 1 | Режим низкой задержки |
Битрейт распределяется по уровням качества — более высокие битрейты (например, 24 Мбит/с) тестировались, но приводили к сбросу кадров кодировщиком/декодером и увеличению сквозной задержки без заметного прироста качества для экранного контента:
| Режим | Битрейт | Разрешение захвата |
|---|---|---|
| HD | 8 Мбит/с | 1080p по короткой стороне |
| Smooth | 4 Мбит/с | 1080p по короткой стороне |
| Low | 2 Мбит/с | 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 -/
| Поле | Размер | Описание |
|---|---|---|
MAGIC | 1 байт | Фиксированное 0x56 ('V'), для валидации |
FLAGS | 1 байт | 0x01=ключевой кадр, 0x02=конфигурация, 0x04=аудио |
FRAME_ID | 4 байта | Монотонно возрастающий номер кадра, uint32 big-endian |
TIMESTAMP | 8 байт | PTS от кодировщика в микросекундах, big-endian |
DATA | переменная | H.264 NAL-единица или Opus-данные |
И VideoPacket.encode() на Android (в commonMain, так что его формат передачи покрывается JVM-юнит-тестами без какой-либо зависимости от Android), и parseVideoPacket() на вебе реализуют этот формат независимо — нет общей библиотеки сериализации, только спецификация, которой обе стороны следуют.
Замечания по дизайну
- Беззнаковый парсинг
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()— это представление исходногоArrayBufferWebSocket, а не копия.
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 — это простой автомат с двумя состояниями:
| Состояние | Поведение |
|---|---|
NORMAL | Декодировать все кадры в обычном режиме |
WAITING_FOR_IDR | Сбрасывать все P-кадры, декодировать только IDR; сброс в NORMAL по прибытии IDR |
Сценарии, инициирующие переход в WAITING_FOR_IDR:
- При запуске: пропуск устаревшего GraphQL-ключа, ожидание реального IDR
- При потере пакетов: сброс недекодируемых P-кадров, ожидание восстановления по IDR
- При ошибке декодера: сброс декодера, ожидание IDR
- При изменении конфигурации: сброс остаточных 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, используемый для масштабирования координат касаний.
rebuildEncoderAndResize():
- Создать новый кодировщик с новыми размерами (например, ландшафт 1920x1080)
- Переключить
VirtualDisplay.surfaceна входнойSurfaceнового кодировщика - Остановить старый кодировщик
VirtualDisplay.resize()на новые размеры
Переключение Surface происходит до изменения размера — гарантируя, что новый кодировщик получает кадры первым, а старый кодировщик останавливается до того, как может получить кадры неверного размера. Если virtualDisplay?.surface = ... выбрасывает исключение, перестроение прерывается и старый кодировщик остаётся работать, чтобы не оставить конвейер без кодировщика вовсе.
Config Change Notification
Когда новый кодировщик впервые выводит SPS/PPS, на конвейере устанавливается pendingConfigBroadcast. Когда прибывает первый IDR от нового кодировщика, он упаковывается вместе с этой конфигурацией в одно событие screen_mirror_video_codec вместо отправки как обычного видеопакета.
Затем веб-клиент в handleConfig():
- Перенастраивает декодер с новыми SPS/PPS
- Немедленно декодирует упакованный IDR-кадр
- Вызывает
video.requestIdr()для сброса любых остаточных P-кадров от старого кодировщика, всё ещё находящихся в пути - Вызывает
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)
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().
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 → парсинг VideoPacket | Uint8Array.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 Guard | if (!running) return в stop() | Предотвращает рекурсию onStop → stop → onDestroy → pipeline.stop → projection.stop → onStop |
| Zero-Copy Pipeline | VideoFrame → gl.texImage2D | Прямая загрузка текстуры GPU, без копирования CPU |
| Two-Pass Scan | avccToAnnexB | Предварительное вычисление размера, единое выделение + массовое копирование, устранение упаковки |
| Bundled Event | SPS/PPS + IDR в одном событии | Изменение конфигурации завершает перенастройку + декодирование первого кадра за одно событие |
| Callback Separation | onFirstFrameRendered vs onDisconnected vs onScreenMirrorOff | Чёткое различие между рендером первого кадра, отказом транспорта и остановкой на телефоне |
| Safety Net | requestIdr() + requestKeyFrame() | Сброс остаточных кадров + запрос чистого IDR после изменения конфигурации |
| PTS Deduplication | timestamp < lastRenderedPts | Сброс неупорядоченных кадров |
| FrameId Gap | frameId > lastFrameId + 1 | Обнаружение потери пакетов без ACK |
| desynchronized Context | WebGL2 desynchronized: true | Обход композитора, экономия 1 кадра задержки |
| Explicit Fail Fast | sendScreenMirrorControl выбрасывает GraphQLError | Сообщает "доступность отключена" вместо молчаливого сброса ввода |
Further Reading
- WebCodecs API — документация MDN, описывающая интерфейсы
VideoDecoder/AudioDecoder.