Update call video controls and sensor rotation across Android and desktop

This commit is contained in:
evgeny committed 2026-10-19 20:24:39 +02:00
1 parent ae1d05deda
commit 10018d008e
36 files changed
+1408 -381

No files matched your search

+54
View File
@@ -1062,3 +1062,57 @@ Linux Qt RX+GUI: 90/90 без KEY/reject, normal ACK p50=33.4мс/p95=37.7мс,
зафиксировал рост silenceGaps 2 -> 4 в realH264RenderingAndClose. Повтор того же бинарника без изменений
прошёл (15/15); stream_view/его audio clock этим изменением не редактировались. Эту временную
нестабильность не скрываем изменением допусков теста. Живой Windows GPU/timer/scanout пока не проверен.
Android: компоновка звонка и поворот передачи, 19.10.2026
Ник находится рядом со стрелкой. В обычном режиме миниатюры занимают место аватара;
TextureView локального и удалённого видео сохраняются при разворачивании. Их положение
берётся из измеренного слота; заполнение экрана не добавляет поворот на 90 градусов.
Панель камеры/кодека/диапазона битрейта/разрешения использует общие CaptureControls со стримом.
Она появляется при любом нажатии кнопки видео, в том числе во время проверки кодеков,
при отказе запуска и отказе в разрешении камеры; сохраняется после выключения передачи.
Первый настоящий TX-кадр также открывает панель. Недоступные опции заблокированы, причина
отказа показана под строкой; доступные настройки можно менять до повторной попытки.
Открытое меню приостанавливает автоскрытие. Показ панели сохраняется при пересоздании экрана звонка.
CallVideoEngine владеет OrientationEventListener вместе с foreground-сервисом; сворачивание
экрана не прекращает отслеживание. Углы датчика направлены противоположно display rotation:
поворот пикселей = sensor + (front ? -deviceAngle : deviceAngle), нормализованный к 0/90/180/270.
Первый достоверный угол принимается сразу; следующие — с гистерезисом 60 градусов и выдержкой
250мс. Неопределённое положение сохраняет последний угол. Экран и локальное превью не следуют
датчику: геометрия превью использует SENSOR_ORIENTATION и фиксированный display rotation.
ImageReader сохраняет размер режима камеры. CallVideoCodec поворачивает Y/U/V непосредственно
при копировании в flexible input MediaCodec, с учётом crop/rowStride/pixelStride и доступных
границ. В потоке rotation=0: пиксели уже ориентированы. lib/video_plane.c собирается с -O2
также в debug APK; без оптимизации на Q8 портретный 720p вход занимал около 48мс вместо 27мс.
Это замер синтетического YUV -> encoder, не гарантия FPS живого звонка.
Поворот на 180 градусов при прежних выходных размерах сохраняет codec/generation и запрашивает
IDR. Перестановка ширины/высоты меняет поколение: raw входы приостановлены, старый encoder
полностью drained, готовые выходы сохранены до принятия портом, затем ожидается tx_idle.
Только после этого публикуется новый формат; подача ждёт configured_generation. tx_idle
означает свободный mailbox независимо от can_encode; уже принятый transport FIFO сохраняет порядок.
Камера/preview при одном повороте не перезапускаются. Откат настроек получает новое поколение,
PTS и frame_id продолжаются; неверный режим диагностируется, при полном отказе останавливается TX.
CAPS сохраняют формат wire. Общий call_video_limit_orientations расширяет обе осевые границы
только после реальной landscape+portrait пробы; предел pixels_per_second остаётся прежним.
Android encoder проверяет обе ориентации каждого предлагаемого размера; desktop encoder —
1280x720 и 720x1280 перед объявлением возможностей. Независимые Android/desktop decoder probes используют
настоящие 1280x720 и 720x1280 KEY; портретные fixtures воспроизводит tools/generate_call_video_portrait.py.
Проверки: test_video_plane (четыре поворота, stride 1/2, interleaved U/V, смещения и точные границы),
test_call_video (портретные CAPS/KEY), test_call_video_port (tx_idle отдельно от can_encode),
CallVideoOrientationTest (направление углов, гистерезис, выдержка, flat/отмена).
Аппаратный debug probe: adb -s SERIAL shell am instrument -w -e rotations 1
com.utun.chat/com.utun.chat.data.CallVideoProbe. Он проверяет цветные квадранты Y/U/V после
настоящего encode/decode, все PTS, четыре поворота и 180 градусов внутри одного encoder.
Результат — INSTRUMENTATION_CODE: 0; подробности и wait/copy/queue timings — tag CallVideoProbe.
В живом звонке call=debug выводит submitMaxUs/inputWaitMaxUs/copyMaxUs/queueMaxUs раз в секунду.
Согласование возможностей и показ опций после отказа проверены сборками Android/core/chatgui.
Android unit: 111/111; шесть native video наборов: 6/6; Qt encoder/clock CTest: 2/2.
Qt bothOrientations кодирует и декодирует H.264/H.265 при переходах 1280x720 -> 720x1280 -> 1280x720,
проверяя поколения и PTS. Аппаратный probe на SM и Q8 проходит с INSTRUMENTATION_CODE: 0;
дополнительно выбирает реальные проверенные режимы в обеих ориентациях через общий C CAPS/JNI.
Это локальные codec-проверки, живой межплатформенный звонок новой пары сборок ещё не проверен.
+2
View File
@@ -25,6 +25,8 @@ libuasync_a_SOURCES = \
radix.h \
serialize.c \
serialize.h \
video_plane.c \
video_plane.h \
reed_solomon.c \
reed_solomon.h \
json_flat.c \
+40
View File
@@ -0,0 +1,40 @@
#include "video_plane.h"
#include "debug_config.h"
#include <string.h>
static int plane_valid(const struct video_plane *p, unsigned width, unsigned height) {
if (!p || !p->data || !p->pixel_stride || p->pixel_stride > 4096 || !p->row_stride || p->row_stride > 16777216) return 0;
size_t row = (width - 1u) * p->pixel_stride + 1u;
return row <= p->row_stride && row <= p->size && height - 1u <= (p->size - row) / p->row_stride;
}
int video_plane_rotate(const struct video_plane *src, const struct video_plane *dst,
unsigned width, unsigned height, unsigned rotation) {
unsigned dw = rotation % 180 ? height : width, dh = rotation % 180 ? width : height;
if (!width || !height || width > 4096 || height > 4096 || rotation > 270 || rotation % 90
|| !plane_valid(src, width, height) || !plane_valid(dst, dw, dh)) {
DEBUG_ERROR(DEBUG_CATEGORY_VIDEO, "YUV plane invalid size=%ux%u rotation=%u src=%zu/%zu/%zu dst=%zu/%zu/%zu",
width, height, rotation, src ? src->size : 0, src ? src->row_stride : 0, src ? src->pixel_stride : 0,
dst ? dst->size : 0, dst ? dst->row_stride : 0, dst ? dst->pixel_stride : 0);
return -1;
}
for (unsigned y = 0; y < dh; y++) {
uint8_t *out = dst->data + y * dst->row_stride;
if (!rotation && src->pixel_stride == 1 && dst->pixel_stride == 1) {
memcpy(out, src->data + y * src->row_stride, width);
continue;
}
size_t start;
ptrdiff_t step;
switch (rotation) {
case 90: start = (height - 1u) * src->row_stride + y * src->pixel_stride; step = -(ptrdiff_t)src->row_stride; break;
case 180: start = (height - 1u - y) * src->row_stride + (width - 1u) * src->pixel_stride;
step = -(ptrdiff_t)src->pixel_stride; break;
case 270: start = (width - 1u - y) * src->pixel_stride; step = (ptrdiff_t)src->row_stride; break;
default: start = y * src->row_stride; step = (ptrdiff_t)src->pixel_stride; break;
}
const uint8_t *in = src->data + start;
for (unsigned x = 0; x < dw; x++) out[x * dst->pixel_stride] = in[(ptrdiff_t)x * step];
}
return 0;
}
+26
View File
@@ -0,0 +1,26 @@
/* Копирование одной плоскости YUV с поворотом по часовой стрелке, без выделений.
* Вызывается отдельно для Y/U/V; размеры chroma уже уменьшены вызывающим.
* Буферы источника и назначения не должны пересекаться. */
#ifndef VIDEO_PLANE_H
#define VIDEO_PLANE_H
#include <stddef.h>
#include <stdint.h>
#ifdef __cplusplus
extern "C" {
#endif
/* data — первый пиксель, size — доступные байты от него; stride — шаг строки/пикселя. */
struct video_plane {
uint8_t *data;
size_t size, row_stride, pixel_stride;
};
/* src_width/height — размеры источника; rotation — 0/90/180/270 градусов.
* При 90/270 размеры назначения переставлены. Возвращает 0 либо -1 с диагностикой;
* все границы проверяются до первой записи. */
int video_plane_rotate(const struct video_plane *src, const struct video_plane *dst,
unsigned src_width, unsigned src_height, unsigned rotation);
#ifdef __cplusplus
}
#endif
#endif
+1
View File
@@ -204,6 +204,7 @@ utun_CORE_SOURCES = \
call/call_video_bitstream.c \
call/call_video_bitstream.h \
call/call_video_fixtures.h \
call/call_video_portrait_fixtures.h \
call/call_video.h \
call/call_video_transport.c \
call/call_video_transport.h \
+16
View File
@@ -53,6 +53,16 @@ int call_video_caps_valid(const struct call_video_caps *caps) {
for (unsigned i = 0; i < 2; i++) if (!limit_valid(&caps->encode[i], i + 1) || !limit_valid(&caps->decode[i], i + 1)) return 0;
return 1;
}
int call_video_limit_orientations(struct call_video_limit *limit, int codec) {
if (!limit || (codec != CALL_VIDEO_H264 && codec != CALL_VIDEO_H265) || !limit_valid(limit, codec) || !limit->width) {
DEBUG_ERROR(DEBUG_CATEGORY_CALL, "video orientation limits invalid codec=%d", codec); return -1;
}
unsigned side = limit->width > limit->height ? limit->width : limit->height;
DEBUG_INFO(DEBUG_CATEGORY_CALL, "video verified orientations codec=%d source=%ux%u axes=%ux%u fps=%u pps=%u",
codec, limit->width, limit->height, side, side, limit->fps, limit->pixels_per_second);
limit->width = limit->height = side;
return 0;
}
static int fits(const struct call_video_limit *l, const struct call_video_format *f) {
return f->width <= l->width && f->height <= l->height && f->fps <= l->fps
&& (uint64_t)f->width * f->height * f->fps <= l->pixels_per_second && f->level <= l->level
@@ -75,6 +85,12 @@ int call_video_choose(const struct call_video_caps *sender, const struct call_vi
for (int codec = 2; codec >= 1; codec--) {
if (format->codec && codec != format->codec) continue;
f.codec = codec;
const struct call_video_limit *tx = &sender->encode[codec - 1], *rx = &receiver->decode[codec - 1];
DEBUG_DEBUG(DEBUG_CATEGORY_CALL, "video mode limits codec=%u request=%ux%u@%u bps=%u "
"encode=%ux%u@%u pps=%u level=%u bps=%u profiles=%u decode=%ux%u@%u pps=%u level=%u bps=%u profiles=%u",
codec, f.width, f.height, f.fps, f.maximum_bps,
tx->width, tx->height, tx->fps, tx->pixels_per_second, tx->level, tx->maximum_bps, tx->profiles,
rx->width, rx->height, rx->fps, rx->pixels_per_second, rx->level, rx->maximum_bps, rx->profiles);
unsigned profiles = sender->encode[codec - 1].profiles & receiver->decode[codec - 1].profiles;
if (format->profile) profiles &= format->profile;
if (!profiles) continue;
+5
View File
@@ -54,6 +54,11 @@ struct call_video_frame {
};
int call_video_caps_valid(const struct call_video_caps *caps);
/* После успешных платформенных проб width×height и height×width расширяет обе осевые
* границы limit до длинной стороны, сохраняя pixel-rate/FPS/bitrate/profile/level.
* codec — 1/2; limit — ненулевые проверенные возможности одного направления.
* 0 — успех, -1 — неверный limit/codec (limit не меняется). */
int call_video_limit_orientations(struct call_video_limit *limit, int codec);
/* format задаёт generation/размер/FPS/rotation/maximum_bps, level=0, profile=0 для auto.
* codec=0: H.265, затем H.264. Успех: codec (1/2), заполняются codec и минимальный level.
* 0 — нет совместимого режима, -1 — неверные аргументы; на отказе format не меняется. */
+8 -2
View File
@@ -1,5 +1,6 @@
#include "call_video_bitstream.h"
#include "call_video_fixtures.h"
#include "call_video_portrait_fixtures.h"
#include "../../lib/debug_config.h"
#include "../../lib/mem.h"
#include <string.h>
@@ -323,10 +324,15 @@ int call_video_check_key(const struct call_video_format *f, const uint8_t *data,
}
return 0;
}
int call_video_probe_key(int codec, int profile, uint8_t *out, size_t capacity) {
int call_video_probe_key(int codec, int profile, int portrait, uint8_t *out, size_t capacity) {
const uint8_t *data = codec == 1 ? call_video_fixture_1 : call_video_fixture_2;
size_t size = codec == 1 ? sizeof(call_video_fixture_1) : sizeof(call_video_fixture_2);
if ((codec != 1 && codec != 2) || (profile != 1 && !(codec == 1 && profile == 2)) || !out || capacity < size)
if (portrait) {
data = codec == 1 ? call_video_portrait_1 : call_video_portrait_2;
size = codec == 1 ? sizeof(call_video_portrait_1) : sizeof(call_video_portrait_2);
}
if ((codec != 1 && codec != 2) || (profile != 1 && !(codec == 1 && profile == 2))
|| (portrait != 0 && portrait != 1) || !out || capacity < size)
return bad("probe key arguments");
memcpy(out, data, size);
if (codec == 1 && profile == 1) out[10] &= ~64u;
+3 -2
View File
@@ -16,8 +16,9 @@ struct call_video_bitstream {
};
int call_video_inspect_key(int codec, const uint8_t *data, size_t length, struct call_video_bitstream *out);
int call_video_check_key(const struct call_video_format *format, const uint8_t *data, size_t length);
/* Копирует эталонный KEY (буфер 8192 байт достаточен), возвращает размер или -1. */
int call_video_probe_key(int codec, int profile, uint8_t *out, size_t capacity);
/* KEY-проба 1280x720 (portrait=0) либо 720x1280 (portrait=1).
* Буфер 8192 байт достаточен; возвращает размер или -1 с диагностикой. */
int call_video_probe_key(int codec, int profile, int portrait, uint8_t *out, size_t capacity);
#ifdef __cplusplus
}
#endif
+5 -2
View File
@@ -55,7 +55,9 @@ static void clear_frames(void) {
}
static void peer(void *arg, const struct call_video_caps *caps) {
pthread_mutex_lock(&lock);
if (port.id == (uint64_t)(uintptr_t)arg) { port.status.peer = *caps; port.status.have_peer = 1; }
if (port.id == (uint64_t)(uintptr_t)arg) {
port.status.peer = *caps; port.status.have_peer = 1;
}
pthread_mutex_unlock(&lock);
}
static int ready(void *arg, uint32_t bytes) {
@@ -254,7 +256,8 @@ int call_video_port_status(uint64_t id, struct call_video_port_status *out) {
pthread_mutex_lock(&lock);
int result = id && id == port.id && port.inst && out ? 0 : -1;
if (!result) {
*out = port.status; out->can_encode = out->can_encode && !port.tx_count && !port.tx_processing;
*out = port.status; out->tx_idle = !port.tx_count && !port.tx_processing;
out->can_encode = out->can_encode && out->tx_idle;
out->rx_frames = port.count;
}
pthread_mutex_unlock(&lock); return result;
+1
View File
@@ -17,6 +17,7 @@ struct call_video_port_status {
uint32_t minimum_bps, maximum_bps;
int have_peer, error;
int can_encode; // Новые raw входы допустимы; готовый TX всё равно сохраняется и повторяется.
int tx_idle; // Mailbox свободен; новый формат не обгонит кадры прежнего поколения в mailbox.
uint64_t decoded_generation;
int decoded_accepted;
unsigned rx_frames; // Полные encoded кадры порта; pending decoder/GUI учитывает RX worker.
+166
View File
@@ -0,0 +1,166 @@
/* 720x1280@30, gray YUV420, ref=3, no B: tools/generate_call_video_portrait.py.
* Configuration + IDR; SEI исключены. */
#ifndef CALL_VIDEO_PORTRAIT_FIXTURES_H
#define CALL_VIDEO_PORTRAIT_FIXTURES_H
#include <stdint.h>
static const uint8_t call_video_portrait_1[] = {
0x00, 0x00, 0x00, 0x25, 0x00, 0x00, 0x00, 0x01, 0x67, 0x42, 0xc0, 0x1f, 0xd9, 0x00, 0xb4, 0x0a, 0x1b, 0x01, 0x10, 0x00,
0x00, 0x03, 0x00, 0x10, 0x00, 0x00, 0x03, 0x03, 0xc8, 0xf1, 0x83, 0x24, 0x80, 0x00, 0x00, 0x00, 0x01, 0x68, 0xcb, 0x83,
0xf2, 0x00, 0x00, 0x00, 0x01, 0x65, 0x88, 0x84, 0x3a, 0x26, 0x28, 0x0c, 0x9c, 0x9c, 0x9c, 0x9c, 0x9c, 0x9c, 0x9c, 0x9c,
0x9c, 0x9c, 0x9c, 0x9c, 0x9c, 0x9c, 0x9c, 0x9c, 0x9c, 0x9c, 0x9c, 0x9c, 0x9c, 0x9c, 0x9c, 0x9c, 0x9c, 0x9c, 0x9c, 0x9c,
0x9c, 0x9c, 0x9c, 0x9c, 0x9c, 0x9c, 0x9c, 0x9c, 0x9c, 0x9c, 0x9c, 0x9c, 0x9c, 0x9c, 0x9c, 0x9d, 0x75, 0xd7, 0x5d, 0x75,
0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d,
0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7,
0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75,
0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d,
0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7,
0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75,
0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d,
0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7,
0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75,
0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d,
0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7,
0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75,
0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d,
0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7,
0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75,
0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d,
0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7,
0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75,
0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d,
0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7,
0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75,
0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d,
0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7,
0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75,
0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d,
0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7,
0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75,
0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d,
0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7,
0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75,
0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d,
0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7,
0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75,
0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d,
0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7,
0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75,
0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d,
0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7,
0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75,
0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d,
0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7,
0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75,
0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d,
0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7,
0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75,
0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d,
0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7,
0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75,
0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d,
0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7,
0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75,
0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d,
0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7,
0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75,
0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d,
0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7,
0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75,
0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d,
0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7,
0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75,
0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d,
0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7,
0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75,
0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d,
0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7,
0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75,
0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d,
0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7,
0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75,
0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d,
0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7,
0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75,
0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d,
0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7,
0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75,
0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d,
0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7,
0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75,
0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d,
0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7,
0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75,
0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d,
0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7,
0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75,
0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d,
0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7,
0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75,
0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d,
0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7,
0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75,
0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d,
0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7,
0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75,
0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d,
0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7,
0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75,
0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d,
0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7,
0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75,
0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d,
0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7,
0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75,
0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d,
0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7,
0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75,
0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d,
0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7,
0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75,
0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d,
0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7,
0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75,
0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d,
0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7,
0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75,
0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d,
0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7,
0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75,
0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d,
0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7,
0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75,
0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d,
0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7,
0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75,
0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d,
0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7,
0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75,
0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d,
0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7,
0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75,
0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d,
0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7,
0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75,
0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d, 0x75, 0xd7, 0x5d,
0x75, 0xd7, 0x80,
};
static const uint8_t call_video_portrait_2[] = {
0x00, 0x00, 0x00, 0x54, 0x00, 0x00, 0x00, 0x01, 0x40, 0x01, 0x0c, 0x01, 0xff, 0xff, 0x01, 0x60, 0x00, 0x00, 0x03, 0x00,
0x90, 0x00, 0x00, 0x03, 0x00, 0x00, 0x03, 0x00, 0x5d, 0x92, 0x80, 0x90, 0x00, 0x00, 0x00, 0x01, 0x42, 0x01, 0x01, 0x01,
0x60, 0x00, 0x00, 0x03, 0x00, 0x90, 0x00, 0x00, 0x03, 0x00, 0x00, 0x03, 0x00, 0x5d, 0xa0, 0x05, 0xa2, 0x00, 0x50, 0x16,
0x59, 0x29, 0x29, 0x30, 0xbc, 0x05, 0xa0, 0x20, 0x00, 0x00, 0x03, 0x00, 0x20, 0x00, 0x00, 0x03, 0x03, 0xc1, 0x00, 0x00,
0x00, 0x01, 0x44, 0x01, 0xc0, 0x73, 0xc0, 0x89, 0x00, 0x00, 0x00, 0x01, 0x28, 0x01, 0xad, 0xe0, 0xd1, 0x17, 0xe7, 0x39,
0x91, 0x77, 0x5d, 0x76, 0xd3, 0x8c, 0x50, 0x00, 0x00, 0x03, 0x01, 0x64, 0x00, 0x00, 0x03, 0x00, 0x00, 0x03, 0x00, 0x00,
0x03, 0x00, 0x00, 0x03, 0x00, 0x00, 0x03, 0x00, 0x00, 0x03, 0x00, 0x00, 0x03, 0x00, 0x00, 0x03, 0x00, 0x00, 0x03, 0x00,
0x00, 0x03, 0x00, 0x00, 0x03, 0x00, 0x00, 0x03, 0x00, 0x00, 0x03, 0x00, 0x00, 0x03, 0x00, 0x00, 0x03, 0x00, 0x00, 0x03,
0x00, 0x00, 0x03, 0x00, 0x00, 0x03, 0x00, 0x00, 0x03, 0x00, 0x00, 0x03, 0x00, 0x00, 0x03, 0x00, 0x00, 0x03, 0x00, 0x00,
0x03, 0x00, 0x00, 0x03, 0x00, 0x00, 0x03, 0x00, 0x00, 0x03, 0x00, 0x00, 0x03, 0x00, 0x00, 0x03, 0x00, 0x00, 0x03, 0x00,
0x00, 0x03, 0x00, 0x00, 0x03, 0x00, 0x00, 0x03, 0x00, 0x00, 0x03, 0x00, 0x00, 0x03, 0x00, 0x00, 0x03, 0x00, 0x00, 0x03,
0x00, 0x00, 0x03, 0x00, 0x00, 0x03, 0x00, 0x00, 0x03, 0x00, 0x00, 0x03, 0x00, 0x00, 0x03, 0x00, 0x00, 0x03, 0x00, 0x00,
0x03, 0x00, 0x00, 0x03, 0x00, 0x00, 0x03, 0x00, 0x00, 0x03, 0x00, 0x00, 0x03, 0x00, 0x00, 0x03, 0x00, 0x00, 0x03, 0x00,
0x00, 0x03, 0x00, 0x00, 0x03, 0x00, 0x00, 0x03, 0x00, 0x00, 0x03, 0x00, 0x00, 0x03, 0x00, 0x00, 0x03, 0x00, 0x00, 0x03,
0x00, 0x00, 0x03, 0x00, 0x00, 0x03, 0x00, 0x00, 0x03, 0x00, 0x00, 0x03, 0x00, 0x00, 0x03, 0x00, 0x00, 0x03, 0x00, 0x00,
0x03, 0x00, 0x00, 0x03, 0x00, 0x00, 0x03, 0x00, 0x00, 0x03, 0x00, 0x00, 0x03, 0x00, 0x00, 0x03, 0x00, 0x00, 0x03, 0x00,
0x00, 0x03, 0x00, 0x55, 0xc0,
};
#endif
+12 -8
View File
@@ -51,6 +51,16 @@ static struct call_session *active(struct UTUN_INSTANCE *inst, uint64_t id) {
struct call_session *s = inst ? call_session_find(inst->call, id) : NULL;
return s && s->state == CALL_ST_ACTIVE ? s : NULL;
}
static void log_caps(uint64_t id, const char *side, const struct call_video_caps *caps) {
for (unsigned i = 0; i < 2; i++) {
const struct call_video_limit *tx = &caps->encode[i], *rx = &caps->decode[i];
DEBUG_DEBUG(DEBUG_CATEGORY_CALL, "video %s limits call=%llu codec=%u "
"encode=%ux%u@%u pps=%u level=%u bps=%u profiles=%u decode=%ux%u@%u pps=%u level=%u bps=%u profiles=%u",
side, (unsigned long long)id, i + 1,
tx->width, tx->height, tx->fps, tx->pixels_per_second, tx->level, tx->maximum_bps, tx->profiles,
rx->width, rx->height, rx->fps, rx->pixels_per_second, rx->level, rx->maximum_bps, rx->profiles);
}
}
static void tick(void *arg);
/* Admission ограничивает новые входы encoder, а готовые кадры продолжают передаваться. */
static void writable(struct call_video_transport *v) {
@@ -324,10 +334,7 @@ int call_video_configure(struct UTUN_INSTANCE *inst, uint64_t id, const struct c
call_video_resume_receive(inst, id);
DEBUG_INFO(DEBUG_CATEGORY_CALL, "video configured call=%llu encode=%u/%u decode=%u/%u", (unsigned long long)id,
v->local.encode[0].width, v->local.encode[1].width, v->local.decode[0].width, v->local.decode[1].width);
for (unsigned i = 0; i < 2; i++) DEBUG_DEBUG(DEBUG_CATEGORY_CALL,
"video local limits codec=%u encode_level=%u bps=%u profiles=%u decode_level=%u bps=%u profiles=%u",
i + 1, v->local.encode[i].level, v->local.encode[i].maximum_bps, v->local.encode[i].profiles,
v->local.decode[i].level, v->local.decode[i].maximum_bps, v->local.decode[i].profiles);
log_caps(id, "local", &v->local);
return control_send(v, VIDEO_CAPS);
}
int call_video_send_frame(struct UTUN_INSTANCE *inst, const struct call_video_frame *f) {
@@ -505,10 +512,7 @@ static void receive(struct ETCP_CONN *conn, struct ll_entry *entry) {
v->peer = caps; v->have_peer = 1;
DEBUG_INFO(DEBUG_CATEGORY_CALL, "video peer capabilities call=%llu encode=%u/%u decode=%u/%u", (unsigned long long)id,
caps.encode[0].width, caps.encode[1].width, caps.decode[0].width, caps.decode[1].width);
for (unsigned i = 0; i < 2; i++) DEBUG_DEBUG(DEBUG_CATEGORY_CALL,
"video peer limits codec=%u encode_level=%u bps=%u profiles=%u decode_level=%u bps=%u profiles=%u",
i + 1, caps.encode[i].level, caps.encode[i].maximum_bps, caps.encode[i].profiles,
caps.decode[i].level, caps.decode[i].maximum_bps, caps.decode[i].profiles);
log_caps(id, "peer", &caps);
if (v->ops.capabilities) v->ops.capabilities(v->arg, &v->peer);
control_send(v, VIDEO_CAPS_ACK);
} else if (data[0] == VIDEO_CAPS_ACK && length == 17 && revision && revision <= v->local_revision) {
+4
View File
@@ -9,6 +9,7 @@ EXTRA_DIST = fixtures/reality_openssl_hello.hex data/silero_speech.pcm data/sile
# Быстрые проверки (до 100мс).
check_PROGRAMS = \
test_video_plane \
test_route_tx_stats \
test_call_video_rate \
test_call_video_playout \
@@ -725,6 +726,9 @@ test_call_video_port_LDADD = $(LIBUTUN) $(CRYPTO_LIBS) $(COMMON_LIBS)
test_call_video_SOURCES = test_call_video.c
test_call_video_LDADD = $(LIBUTUN) $(COMMON_LIBS)
test_video_plane_SOURCES = test_video_plane.c
test_video_plane_LDADD = ../lib/libuasync.a $(COMMON_LIBS)
test_call_video_flush_SOURCES = test_call_video_flush.c
test_call_video_flush_LDADD = $(LIBUTUN) $(CRYPTO_LIBS) $(COMMON_LIBS)
test_call_CFLAGS = -I$(top_srcdir)/src -I$(top_srcdir)/src/chat -I$(top_srcdir)/src/dm -I$(top_srcdir)/src/call -I$(top_srcdir)/src/routing_layer -I$(top_srcdir)/src/transport_layer -I$(top_srcdir)/lib
+28 -6
View File
@@ -34,6 +34,27 @@ static void capabilities(void) {
CHECK(call_video_caps_parse(wire, sizeof(wire), &decoded) == -1 && !memcmp(&saved, &decoded, sizeof(saved)));
puts("video: directional capabilities, HEVC preference, AVC fallback, pixel-rate limits PASS");
}
static void portrait_limits(void) {
struct call_video_caps sender = {0}, receiver = {0};
for (int codec = 1; codec <= 2; codec++) {
struct call_video_limit limit = {1280, 720, 30, 1280 * 720 * 30, 4000000, 1, codec == 1 ? 31 : 93};
sender.encode[codec - 1] = receiver.decode[codec - 1] = limit;
CHECK(choose(&sender, &receiver, codec, 720, 1280, 30) == 0);
CHECK(call_video_limit_orientations(&sender.encode[codec - 1], codec) == 0);
CHECK(choose(&sender, &receiver, codec, 720, 1280, 30) == 0);
CHECK(call_video_limit_orientations(&receiver.decode[codec - 1], codec) == 0);
CHECK(sender.encode[codec - 1].pixels_per_second == limit.pixels_per_second);
CHECK(choose(&sender, &receiver, codec, 720, 1280, 30) == codec);
CHECK(choose(&sender, &receiver, codec, 1280, 720, 30) == codec);
CHECK(choose(&sender, &receiver, codec, 1280, 1280, 30) == 0);
struct call_video_limit invalid = limit, saved;
invalid.fps = 0; saved = invalid;
CHECK(call_video_limit_orientations(&invalid, codec) == -1 && !memcmp(&invalid, &saved, sizeof(saved)));
CHECK(call_video_limit_orientations(&limit, 3) == -1 && limit.height == 720);
CHECK(call_video_limit_orientations(NULL, codec) == -1);
}
puts("video: portrait requires both directed CAPS, pixel-rate cap remains enforced PASS");
}
static void envelopes(void) {
struct call_video_caps a = {0}, b = {0};
a.encode[0] = b.decode[0] = (struct call_video_limit){1920, 1080, 60, 1920u * 1080u * 60u, 4000000, 3, 42};
@@ -138,12 +159,13 @@ static void rejected(void) {
static void bitstreams(void) {
uint8_t bytes[8192]; struct call_video_bitstream s;
for (int codec = 1; codec <= 2; ++codec) {
for (int profile = 1; profile <= (codec == 1 ? 2 : 1); ++profile) {
int size = call_video_probe_key(codec, profile, bytes, sizeof(bytes)); CHECK(size > 0);
for (int profile = 1; profile <= (codec == 1 ? 2 : 1); ++profile) for (int portrait = 0; portrait <= 1; portrait++) {
int size = call_video_probe_key(codec, profile, portrait, bytes, sizeof(bytes)); CHECK(size > 0);
CHECK(!call_video_inspect_key(codec, bytes, size, &s));
CHECK(s.width == 1280 && s.height == 720 && s.profile == (unsigned)profile && s.level == (codec == 1 ? 31u : 93u));
CHECK(s.width == (portrait ? 720u : 1280u) && s.height == (portrait ? 1280u : 720u)
&& s.profile == (unsigned)profile && s.level == (codec == 1 ? 31u : 93u));
CHECK(s.dpb >= 3);
struct call_video_format f = {1, 1280, 720, 30, 0, codec, s.level, 4000000, profile};
struct call_video_format f = {1, s.width, s.height, 30, 0, codec, s.level, 4000000, profile};
CHECK(!call_video_check_key(&f, bytes, size));
f.width = 640; CHECK(call_video_check_key(&f, bytes, size) < 0);
struct call_video_bitstream saved = s;
@@ -178,7 +200,7 @@ static size_t hevc_segment(uint8_t *data, unsigned first, unsigned enabled, unsi
}
static void hevc_pps_flags(void) {
uint8_t bytes[8192]; struct call_video_bitstream stream;
int size = call_video_probe_key(CALL_VIDEO_H265, CALL_VIDEO_MAIN, bytes, sizeof(bytes)); CHECK(size > 0);
int size = call_video_probe_key(CALL_VIDEO_H265, CALL_VIDEO_MAIN, 0, bytes, sizeof(bytes)); CHECK(size > 0);
size_t pps = 0, config = 4 + ((unsigned)bytes[2] << 8 | bytes[3]);
for (size_t i = 4; i + 5 < config; ++i)
if (!bytes[i] && !bytes[i + 1] && bytes[i + 2] == 1 && bytes[i + 3] == 68) pps = i + 5;
@@ -209,5 +231,5 @@ static void hevc_pps_flags(void) {
}
int main(void) {
debug_config_init(); debug_set_level(DEBUG_LEVEL_ERROR);
capabilities(); envelopes(); roundtrip(); rejected(); bitstreams(); hevc_pps_flags(); return 0;
capabilities(); portrait_limits(); envelopes(); roundtrip(); rejected(); bitstreams(); hevc_pps_flags(); return 0;
}
+5 -2
View File
@@ -63,14 +63,17 @@ int main(void) {
assert(accepted == 2 && port.tx_count == 6 && !port.tx_processing && !port.pending);
unsigned before = offered; uasync_poll(ua, 0); assert(offered == before); // Нет busy loop.
struct call_video_port_status status;
assert(!call_video_port_status(7, &status) && !status.can_encode && !status.key_serial);
assert(!call_video_port_status(7, &status) && !status.can_encode && !status.tx_idle && !status.key_serial);
for (unsigned i = 0; i < 3; ++i) {
callbacks->writable(callback_arg, 0); uasync_poll(ua, 0);
assert(accepted == 2 && port.tx_count == 6 && !port.pending);
}
allowance = 8; callbacks->writable(callback_arg, 1); uasync_poll(ua, 0);
assert(accepted == 8 && !port.tx_count);
assert(!call_video_port_status(7, &status) && status.can_encode && !status.key_serial);
assert(!call_video_port_status(7, &status) && status.can_encode && status.tx_idle && !status.key_serial);
callbacks->writable(callback_arg, 0);
assert(!call_video_port_status(7, &status) && !status.can_encode && status.tx_idle);
callbacks->writable(callback_arg, 1);
DEBUG_INFO(DEBUG_CATEGORY_CALL, "PASS video port: all 8 frames preserved across partial send and repeated busy; no KEY or spin");
for (unsigned i = 0; i < 8; ++i) { assert(callbacks->receive_ready(callback_arg, 16)); callbacks->frame(callback_arg, &frames[i]); }
assert(woken == 8 && !call_video_port_status(7, &status) && status.rx_frames == 8);
+41
View File
@@ -0,0 +1,41 @@
#include "../lib/video_plane.h"
#include <assert.h>
#include <stdio.h>
#include <string.h>
int main(void) {
static const uint8_t expected[4][8] = {{1,2,3,4,5,6,7,8}, {5,1,6,2,7,3,8,4},
{8,7,6,5,4,3,2,1}, {4,8,3,7,2,6,1,5}};
for (unsigned rotation = 0; rotation < 360; rotation += 90) {
unsigned w = rotation % 180 ? 2 : 4, h = rotation % 180 ? 4 : 2;
for (unsigned sp = 1; sp <= 2; sp++) for (unsigned dp = 1; dp <= 2; dp++) {
uint8_t source[128], output[128], before[128], touched[128] = {0};
memset(source, 0xcc, sizeof(source)); memset(output, 0xee, sizeof(output));
struct video_plane src = {source + 7, 0, 4 * sp + 3, sp};
struct video_plane dst = {output + 9, 0, w * dp + 5, dp};
src.size = src.row_stride + 3 * sp + 1;
dst.size = (h - 1) * dst.row_stride + (w - 1) * dp + 1;
for (unsigned y = 0; y < 2; y++) for (unsigned x = 0; x < 4; x++) src.data[y * src.row_stride + x * sp] = 1 + y * 4 + x;
assert(!video_plane_rotate(&src, &dst, 4, 2, rotation));
for (unsigned y = 0; y < h; y++) for (unsigned x = 0; x < w; x++) {
unsigned at = 9 + y * dst.row_stride + x * dp;
assert(output[at] == expected[rotation / 90][y * w + x]); touched[at] = 1;
}
for (unsigned i = 0; i < sizeof(output); i++) if (!touched[i]) assert(output[i] == 0xee);
memcpy(before, output, sizeof(output));
dst.size--; assert(video_plane_rotate(&src, &dst, 4, 2, rotation) < 0);
assert(!memcmp(before, output, sizeof(output)));
dst.size++; src.size--; assert(video_plane_rotate(&src, &dst, 4, 2, rotation) < 0);
assert(!memcmp(before, output, sizeof(output)));
}
}
uint8_t uv[] = {1,101,2,102,3,103,4,104}, rotated[8] = {0};
struct video_plane src = {uv, sizeof(uv), 4, 2}, dst = {rotated, sizeof(rotated), 4, 2};
assert(!video_plane_rotate(&src, &dst, 2, 2, 90));
src.data++; src.size--; dst.data++; dst.size--;
assert(!video_plane_rotate(&src, &dst, 2, 2, 90));
static const uint8_t expected_uv[] = {3,103,1,101,4,104,2,102};
assert(!memcmp(rotated, expected_uv, sizeof(rotated)));
puts("YUV: all rotations, planar/interleaved strides, cropped starts, exact bounds and guards PASS");
return 0;
}
@@ -7,12 +7,14 @@ import android.media.ImageReader
import android.media.ImageWriter
import android.os.Bundle
/** Запускается только явно через adb am instrument; камера и реальные звонки не затрагиваются. */
/** Явный запуск через adb am instrument вне живого звонка; без camera capture и сетевой передачи. */
class CallVideoProbe : Instrumentation() {
private var remote: String? = null
private var selectedCodec = 0
private var rotations = false
override fun onCreate(arguments: Bundle?) {
remote = arguments?.getString("remote"); selectedCodec = arguments?.getString("codec")?.toIntOrNull() ?: 0
rotations = arguments?.getString("rotations") == "1"
super.onCreate(arguments); start()
}
override fun onStart() {
@@ -33,6 +35,30 @@ class CallVideoProbe : Instrumentation() {
val codecs = CallVideoCodec.probe()
check(codecs.isNotEmpty() && decoders.isNotEmpty()) { "No verified encoders or decoders" }
check(selectedCodec == 0 || selectedCodec in codecs) { "Requested codec unavailable: $selectedCodec" }
for ((codec, entry) in codecs) {
val decoder = decoders[codec] ?: continue
val bytes = java.nio.ByteBuffer.allocate(64).order(java.nio.ByteOrder.BIG_ENDIAN)
val size = entry.sizes.maxBy { it.width * it.height }
bytes.position((codec - 1) * 16)
bytes.putShort(size.width.toShort()).putShort(size.height.toShort()).putShort(30)
.putInt(size.width * size.height * 30).putInt(4_000_000).put(entry.level.toByte()).put(entry.profile.toByte())
bytes.position(32 + (codec - 1) * 16)
bytes.putShort(1280).putShort(720).putShort(30).putInt(1280 * 720 * 30)
.putInt(4_000_000).put((if (codec == 1) 31 else 93).toByte()).put(decoder.profiles.toByte())
val caps = checkNotNull(CallVideoNative.rotatableCaps(bytes.array())) { "Verified CAPS rejected codec=$codec" }
for (mode in entry.sizes) for (portrait in listOf(false, true)) {
val desired = CallVideoFormat(1, if (portrait) mode.height else mode.width,
if (portrait) mode.width else mode.height, codec = codec)
val selected = checkNotNull(CallVideoNative.choose(caps, caps, desired.array())) { "No common mode $desired" }
check(CallVideoNative.supported(caps, caps, selected)) { "Chosen mode rejected $desired" }
}
android.util.Log.i("CallVideoProbe", "verified directed CAPS codec=$codec sizes=${entry.sizes}; both orientations agreed")
}
if (rotations) {
CallVideoRotationProbe.run(codecs, decoders, selectedCodec)
finish(0, Bundle().apply { putString("rotations", "All YUV corners, PTS and four rotations verified; codecs=${codecs.keys}") })
return
}
for ((codec, entry) in codecs) {
if (selectedCodec != 0 && selectedCodec != codec) continue
val f = CallVideoFormat(1, 640, 480, codec = codec, level = entry.level, profile = entry.profile)
@@ -0,0 +1,130 @@
package com.utun.chat.data
import android.graphics.ImageFormat
import android.media.Image
import android.media.ImageReader
import android.media.ImageWriter
import android.media.MediaCodec
import android.media.MediaCodecInfo
import android.media.MediaFormat
import android.util.Log
import android.util.Size
import kotlin.math.abs
/** Проверяет настоящие YUV входы, bitstream и декодированные углы; камера/звонок не нужны. */
internal object CallVideoRotationProbe {
private val corners = arrayOf(intArrayOf(0, 1, 2, 3), intArrayOf(2, 0, 3, 1), intArrayOf(3, 2, 1, 0), intArrayOf(1, 3, 0, 2))
private val colors = arrayOf(intArrayOf(32, 80, 144, 208), intArrayOf(80, 100, 120, 140), intArrayOf(160, 180, 200, 220))
fun run(codecs: Map<Int, CallVideoCodec.Companion.Encoder>, decoders: Map<Int, CallVideoCodec.Companion.Decoder>, selected: Int) {
for ((codec, entry) in codecs) {
if (selected != 0 && selected != codec) continue
val decoder = checkNotNull(decoders[codec]) { "No decoder for rotation test codec=$codec" }
for (size in entry.sizes) verify(size, codec, entry, decoder.name)
}
}
private fun verify(size: Size, codec: Int, entry: CallVideoCodec.Companion.Encoder, decoder: String) {
ImageReader.newInstance(size.width, size.height, ImageFormat.YUV_420_888, 2).use { reader ->
ImageWriter.newInstance(reader.surface, 1).use { writer -> writer.dequeueInputImage().use { source ->
fill(source)
// Два направления одной геометрии идут через один encoder, как поворот телефона на 180°.
for (quarter in 0..1) {
val format = CallVideoFormat(1, if (quarter == 0) size.width else size.height,
if (quarter == 0) size.height else size.width, codec = codec, level = entry.level, profile = entry.profile)
CallVideoCodec(format, 1_000_000, entry.name).use { encoder ->
encoder.prepare()
val packets = mutableListOf<CallVideoCodec.Packet>()
var sent = 0; var elapsed = 0L; var maximum = 0L
var waitUs = 0L; var copyUs = 0L; var queueUs = 0L
val deadline = System.nanoTime() + 8_000_000_000L
while (packets.size < 12 && System.nanoTime() < deadline) {
if (sent < 12) {
val rotation = (quarter + if (sent < 6) 0 else 2) * 90
val started = System.nanoTime()
if (encoder.submit(source, 10_000_000L + sent * 33333, sent == 0 || sent == 6, rotation)) {
val us = (System.nanoTime() - started) / 1000
elapsed += us; maximum = maxOf(maximum, us); sent++
waitUs += encoder.inputWaitUs; copyUs += encoder.copyUs; queueUs += encoder.queueUs
}
}
packets += encoder.drain()
Thread.sleep(1)
}
check(sent == 12 && packets.size == 12 && packets.first().key) { "Incomplete rotated encoder output" }
decode(encoder.format, packets, decoder, quarter)
val result = "codec=$codec name=${entry.name} source=$size output=${format.width}x${format.height} " +
"rotations=${quarter * 90}/${(quarter + 2) * 90} frames=$sent submitAvgUs=${elapsed / sent} submitMaxUs=$maximum " +
"waitAvgUs=${waitUs / sent} copyAvgUs=${copyUs / sent} queueAvgUs=${queueUs / sent}"
Log.i("CallVideoProbe", "rotation check $result")
check(elapsed / sent < 33333) { "Rotation cannot sustain 30fps: $result" }
}
}
} }
}
}
private fun fill(image: Image) {
for ((index, plane) in image.planes.withIndex()) {
val shift = if (index == 0) 0 else 1
val width = image.width shr shift; val height = image.height shr shift
val buffer = plane.buffer.duplicate(); val start = buffer.position()
for (y in 0 until height) for (x in 0 until width) {
val quadrant = (if (y < height / 2) 0 else 2) + if (x < width / 2) 0 else 1
buffer.put(start + y * plane.rowStride + x * plane.pixelStride, colors[index][quadrant].toByte())
}
}
}
private fun decode(format: CallVideoFormat, packets: List<CallVideoCodec.Packet>, name: String, quarter: Int) {
val codec = MediaCodec.createByCodecName(name)
try {
val type = StreamVideoCodec.fromId(format.codec)
val config = type.unpackKey(packets.first().bytes).first
val media = CallVideoCodec.decoderFormat(format, codec, config).apply {
setInteger(MediaFormat.KEY_COLOR_FORMAT, MediaCodecInfo.CodecCapabilities.COLOR_FormatYUV420Flexible)
}
codec.configure(media, null, null, 0); codec.start()
var sent = 0; var decoded = 0
val info = MediaCodec.BufferInfo(); val deadline = System.nanoTime() + 8_000_000_000L
while (decoded < packets.size && System.nanoTime() < deadline) {
if (sent < packets.size) {
val input = codec.dequeueInputBuffer(0)
if (input >= 0) {
val packet = packets[sent++]
val bytes = if (packet.key) type.unpackKey(packet.bytes).let { it.first + it.second } else packet.bytes
codec.getInputBuffer(input)!!.put(bytes)
codec.queueInputBuffer(input, 0, bytes.size, packet.pts, 0)
}
}
val output = codec.dequeueOutputBuffer(info, 1000)
if (output >= 0) {
try {
check(info.presentationTimeUs == packets[decoded].pts) { "Rotation reordered PTS" }
codec.getOutputImage(output).use { image ->
checkNotNull(image) { "Decoder has no readable YUV image" }
val crop = image.cropRect
check(crop.width() == format.width && crop.height() == format.height) { "Decoded geometry changed" }
val rotation = quarter + if (decoded < 6) 0 else 2
for ((index, plane) in image.planes.withIndex()) {
val shift = if (index == 0) 0 else 1
val w = crop.width() shr shift; val h = crop.height() shr shift
val buffer = plane.buffer.duplicate()
for (corner in 0..3) {
val x = (crop.left shr shift) + w * (if (corner % 2 == 0) 1 else 3) / 4
val y = (crop.top shr shift) + h * (if (corner < 2) 1 else 3) / 4
val actual = buffer.get(buffer.position() + y * plane.rowStride + x * plane.pixelStride).toInt() and 255
val expected = colors[index][corners[rotation][corner]]
check(abs(actual - expected) <= 12) { "Rotated pixel mismatch frame=$decoded plane=$index corner=$corner actual=$actual expected=$expected" }
}
}
}
decoded++
} finally { codec.releaseOutputBuffer(output, false) }
}
}
check(decoded == packets.size) { "Incomplete rotated decoder output $decoded/${packets.size}" }
codec.stop()
} finally { codec.release() }
}
}
@@ -65,6 +65,8 @@ set_target_properties(onnxruntime PROPERTIES
# OBJECT library: compile .c once, link into SHARED
add_library(utun_core OBJECT ${UTUN_SOURCES})
# Покадровый YUV-цикл должен укладываться в 30fps и в debug APK; остальные исходники сохраняют режим сборки.
set_source_files_properties("${LIB_DIR}/video_plane.c" PROPERTIES COMPILE_OPTIONS "-O2")
target_include_directories(utun_core PRIVATE
${UTUN_INCLUDE_DIRS} ${JNI_DIR} ${OPENSSL_DIR}/include ${ORT_DIR}/include
)
@@ -7,7 +7,7 @@ import android.media.MediaCodecList
import android.media.MediaFormat
import android.os.Build
import android.os.Bundle
import java.nio.ByteBuffer
import android.util.Size
/** Codec принадлежит одному worker. YUV input отделён от камеры: замена на IDR не меняет capture PTS. */
internal class CallVideoCodec(requested: CallVideoFormat, bitrate: Int, val name: String) : AutoCloseable {
@@ -27,6 +27,12 @@ internal class CallVideoCodec(requested: CallVideoFormat, bitrate: Int, val name
private var lastInput = -1L
var pendingFrames = 0
private set
var inputWaitUs = 0L
private set
var copyUs = 0L
private set
var queueUs = 0L
private set
init {
try {
@@ -49,30 +55,45 @@ internal class CallVideoCodec(requested: CallVideoFormat, bitrate: Int, val name
} catch (e: Exception) { codec.release(); throw e }
}
fun submit(image: Image?, pts: Long, key: Boolean): Boolean {
fun submit(image: Image?, pts: Long, key: Boolean, rotation: Int = 0): Boolean {
val started = System.nanoTime()
val index = codec.dequeueInputBuffer(0)
if (index < 0) return false
if (key) codec.setParameters(Bundle().apply { putInt(MediaCodec.PARAMETER_KEY_REQUEST_SYNC_FRAME, 0) })
val input = checkNotNull(codec.getInputImage(index)) { "Encoder lacks flexible YUV input" }
check(input.width >= format.width && input.height >= format.height)
val crop = image?.cropRect
if (crop != null) {
check(crop.left % 2 == 0 && crop.top % 2 == 0)
check((if (rotation % 180 == 0) crop.width() else crop.height()) == format.width
&& (if (rotation % 180 == 0) crop.height() else crop.width()) == format.height) { "YUV input geometry mismatch" }
}
val acquired = System.nanoTime()
for (plane in 0..2) {
val w = format.width shr (if (plane == 0) 0 else 1)
val h = format.height shr (if (plane == 0) 0 else 1)
val dst = input.planes[plane]; val src = image?.planes?.get(plane)
if (src != null && crop != null) {
val shift = if (plane == 0) 0 else 1
val source = src.buffer.duplicate().apply {
position(position() + (crop.top shr shift) * src.rowStride + (crop.left shr shift) * src.pixelStride)
}.slice()
check(CallVideoNative.rotatePlane(source, src.rowStride, src.pixelStride, dst.buffer.slice(), dst.rowStride,
dst.pixelStride, crop.width() shr shift, crop.height() shr shift, rotation)) { "YUV plane rotation failed" }
continue
}
val row = ByteArray(w)
for (y in 0 until h) {
if (src == null) row.fill(if (plane == 0) 64 else 128.toByte())
else {
val buffer = src.buffer.duplicate(); val start = buffer.position() + y * src.rowStride
if (src.pixelStride == 1) { buffer.position(start); buffer.get(row) }
else for (x in 0 until w) row[x] = buffer.get(start + x * src.pixelStride)
}
row.fill(if (plane == 0) 64 else 128.toByte())
val buffer = dst.buffer.duplicate(); val start = buffer.position() + y * dst.rowStride
if (dst.pixelStride == 1) { buffer.position(start); buffer.put(row) }
else for (x in 0 until w) buffer.put(start + x * dst.pixelStride, row[x])
}
}
val copied = System.nanoTime()
input.close(); codec.queueInputBuffer(index, 0, format.width * format.height * 3 / 2, pts, 0)
inputWaitUs = (acquired - started) / 1000; copyUs = (copied - acquired) / 1000
queueUs = (System.nanoTime() - copied) / 1000
lastInput = pts; pendingFrames++; return true
}
@@ -180,7 +201,7 @@ internal class CallVideoCodec(requested: CallVideoFormat, bitrate: Int, val name
93 -> MediaCodecInfo.CodecProfileLevel.HEVCMainTierLevel31
else -> error("Unsupported HEVC level $level")
}
data class Encoder(val name: String, val profile: Int, val level: Int)
data class Encoder(val name: String, val profile: Int, val level: Int, val sizes: List<Size>)
data class Decoder(val name: String, val profiles: Int, val pipeline: Int = 0)
/** Аппаратные реализации проверяются первыми; software остаётся резервом после реальной пробы. */
private fun candidates(encoder: Boolean) = MediaCodecList(MediaCodecList.REGULAR_CODECS).codecInfos
@@ -205,16 +226,30 @@ internal class CallVideoCodec(requested: CallVideoFormat, bitrate: Int, val name
for (entry in candidates(true)) {
if (entry.supportedTypes.none { it == type.mime }) continue
try {
val f = CallVideoFormat(1, 1280, 720, codec = type.id, level = if (type.id == 1) 31 else 93,
profile = if (type.id == 1) 2 else 1)
val caps = entry.getCapabilitiesForType(type.mime)
if (caps.videoCapabilities?.areSizeAndRateSupported(1280, 720, 30.0) != true
|| caps.encoderCapabilities?.isBitrateModeSupported(MediaCodecInfo.EncoderCapabilities.BITRATE_MODE_CBR) != true) continue
CallVideoCodec(f, 1_000_000, entry.name).use { encoder ->
encoder.prepare()
result[type.id] = Encoder(entry.name, encoder.format.profile, encoder.format.level)
if (caps.encoderCapabilities?.isBitrateModeSupported(MediaCodecInfo.EncoderCapabilities.BITRATE_MODE_CBR) != true) continue
val sizes = mutableListOf<Size>(); var profile = 0; var level = 0
for (size in listOf(Size(640, 480), Size(1280, 720))) {
try {
var foundProfile = profile; var foundLevel = level
for ((w, h) in listOf(size.width to size.height, size.height to size.width)) {
check(caps.videoCapabilities?.areSizeAndRateSupported(w, h, 30.0) == true) { "Geometry unsupported: ${w}x$h" }
val f = CallVideoFormat(1, w, h, codec = type.id, level = if (type.id == 1) 31 else 93,
profile = if (type.id == 1) 2 else 1)
CallVideoCodec(f, 1_000_000, entry.name).use { encoder ->
encoder.prepare()
check(foundProfile == 0 || foundProfile == encoder.format.profile) { "Geometry changes profile" }
foundProfile = encoder.format.profile; foundLevel = maxOf(foundLevel, encoder.format.level)
}
}
profile = foundProfile; level = foundLevel; sizes += size
} catch (e: Exception) {
LogManager.addLog("WARN", "call", "video orientation probe rejected ${entry.name} size=$size: ${e.message}")
}
}
LogManager.addLog("INFO", "call", "video codec probe passed type=${type.label} encoder=${entry.name}"); break
if (sizes.isEmpty()) continue
result[type.id] = Encoder(entry.name, profile, level, sizes)
LogManager.addLog("INFO", "call", "video codec probe passed type=${type.label} encoder=${entry.name} sizes=$sizes"); break
} catch (e: Exception) { LogManager.addLog("WARN", "call", "video codec probe rejected ${entry.name}: ${e.message}") }
}
}
@@ -230,11 +265,15 @@ internal class CallVideoCodec(requested: CallVideoFormat, bitrate: Int, val name
if (caps.videoCapabilities?.areSizeAndRateSupported(1280, 720, 30.0) != true) continue
var profiles = 0; var pipeline = 0
for (profile in if (type.id == 1) listOf(1, 2) else listOf(1)) {
val f = CallVideoFormat(1, 1280, 720, codec = type.id, level = if (type.id == 1) 31 else 93, profile = profile)
val key = checkNotNull(CallVideoNative.probeKey(type.id, profile))
validateKey(key, f)
pipeline = maxOf(pipeline, verifyDecode(f, List(8) { Packet(1_000_000L + it * 33333, true, key) },
entry.name, true))
for (portrait in listOf(false, true)) {
val w = if (portrait) 720 else 1280; val h = if (portrait) 1280 else 720
check(caps.videoCapabilities?.areSizeAndRateSupported(w, h, 30.0) == true) { "Decoder geometry unsupported: ${w}x$h" }
val f = CallVideoFormat(1, w, h, codec = type.id, level = if (type.id == 1) 31 else 93, profile = profile)
val key = checkNotNull(CallVideoNative.probeKey(type.id, profile, portrait))
validateKey(key, f)
pipeline = maxOf(pipeline, verifyDecode(f, List(8) { Packet(1_000_000L + it * 33333, true, key) },
entry.name, true))
}
profiles = profiles or profile
}
result[type.id] = Decoder(entry.name, profiles, pipeline)
@@ -15,17 +15,25 @@ import android.os.HandlerThread
import android.os.SystemClock
import android.util.Size
import android.view.Surface
import android.view.OrientationEventListener
import com.utun.chat.R
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import java.nio.ByteBuffer
import java.nio.ByteOrder
data class CallVideoCamera(val id: String, val name: String, val sizes: List<Size>)
data class CallVideoCamera(val id: String, val name: String, val sizes: List<Size>, val sensor: Int, val front: Boolean)
data class CallVideoSettings(val enabled: Boolean = false, val camera: String = "", val width: Int = 640,
val height: Int = 480, val codec: Int = 0)
data class CallVideoState(val ready: Boolean = false, val settings: CallVideoSettings = CallVideoSettings(),
val cameras: List<CallVideoCamera> = emptyList(), val codecs: List<Int> = emptyList(), val bitrate: Int = 0,
val range: CallVideoBitrateRange = CallVideoBitrateRange(), val status: String = "")
val range: CallVideoBitrateRange = CallVideoBitrateRange(), val status: String = "", val transmitting: Boolean = false,
val codecSizes: Map<Int, List<Size>> = emptyMap(), val notice: Int? = null) {
fun sizes(camera: String = settings.camera, codec: Int = settings.codec): List<Size> {
val allowed = if (codec == 0) codecSizes.values.flatten() else codecSizes[codec].orEmpty()
return cameras.firstOrNull { it.id == camera }?.sizes.orEmpty().filter { it in allowed }
}
}
/** Сервис владеет capture и RX; экран только выбирает режим и передаёт Surface. */
class CallVideoEngine(private val context: Context, private val id: Long) {
@@ -76,6 +84,40 @@ class CallVideoEngine(private val context: Context, private val id: Long) {
private data class Pending(val metadata: LongArray, val bytes: ByteArray)
private val pendingFrames = java.util.ArrayDeque<Pending>()
private var canEncode = false
private var switching = false
private var encoderDrained = false
private var appliedRotation = -1
private var transformMaxUs = 0L
private var inputWaitMaxUs = 0L
private var copyMaxUs = 0L
private var queueMaxUs = 0L
private val orientation = CallVideoOrientation()
private var orientationEnabled = false
private val orientationListener = object : OrientationEventListener(context) {
override fun onOrientationChanged(angle: Int) {
if (!handler.post {
if (!stopped && orientationEnabled && orientation.observe(angle, SystemClock.elapsedRealtime())) logOrientation()
}) LogManager.addLog("WARN", "call", "orientation after video worker stopped id=$id")
}
}
private fun logOrientation() {
LogManager.addLog("INFO", "call", "video device orientation id=$id degrees=${orientation.degrees}; display and preview unchanged")
}
private fun observeOrientation(enabled: Boolean) {
if (orientationEnabled == enabled) return
orientationEnabled = enabled; orientation.resetPending()
if (!enabled) orientationListener.disable()
else if (orientationListener.canDetectOrientation()) orientationListener.enable()
else LogManager.addLog("WARN", "call", "video orientation sensor unavailable id=$id; using ${orientation.degrees}")
LogManager.addLog("DEBUG", "call", "video orientation monitoring id=$id enabled=$enabled")
}
private fun rotation(settings: CallVideoSettings): Int {
val camera = checkNotNull(mutable.value.cameras.firstOrNull { it.id == settings.camera }) { "Camera unavailable" }
return orientation.rotation(camera.sensor, camera.front)
}
init {
preview.bind { target ->
@@ -97,19 +139,30 @@ class CallVideoEngine(private val context: Context, private val id: Long) {
val c = manager.getCameraCharacteristics(lens.cameraId)
val sizes = c.get(CameraCharacteristics.SCALER_STREAM_CONFIGURATION_MAP)?.getOutputSizes(ImageFormat.YUV_420_888)
.orEmpty().filter { it == Size(640, 480) || it == Size(1280, 720) }
if (sizes.isEmpty()) null else CallVideoCamera(lens.cameraId, lens.label, sizes)
val supported = sizes.filter { size -> encoders.values.any { size in it.sizes } }
if (supported.isEmpty()) null else CallVideoCamera(lens.cameraId, lens.label, supported,
c.get(CameraCharacteristics.SENSOR_ORIENTATION) ?: 0,
c.get(CameraCharacteristics.LENS_FACING) == CameraCharacteristics.LENS_FACING_FRONT)
}
val bytes = ByteBuffer.wrap(caps).order(ByteOrder.BIG_ENDIAN)
repeat(2) { direction -> for (codec in 1..2) {
val profile = if (direction == 0) encoders[codec]?.profile else decoders[codec]?.profiles
val level = if (direction == 0) encoders[codec]?.level else if (codec == 1) 31 else 93
if (profile != null && level != null) bytes.putShort(1280).putShort(720).putShort(30).putInt(1280 * 720 * 30)
.putInt(4_000_000).put(level.toByte()).put(profile.toByte())
if (profile != null && level != null) {
val size = if (direction == 0) encoders.getValue(codec).sizes.maxBy { it.width * it.height } else Size(1280, 720)
bytes.putShort(size.width.toShort()).putShort(size.height.toShort()).putShort(30).putInt(size.width * size.height * 30)
.putInt(4_000_000).put(level.toByte()).put(profile.toByte())
}
else bytes.position(bytes.position() + 16)
} }
caps = checkNotNull(CallVideoNative.rotatableCaps(caps)) { "Invalid verified video capabilities" }
check(CallVideoNative.open(id, caps)) { "Video port unavailable" }
wanted = wanted.copy(camera = cameras.firstOrNull()?.id.orEmpty())
mutable.value = mutable.value.copy(ready = true, cameras = cameras, codecs = encoders.keys.toList(), settings = wanted)
val firstCamera = cameras.firstOrNull()
val firstSize = firstCamera?.sizes?.firstOrNull { it.width == wanted.width && it.height == wanted.height }
?: firstCamera?.sizes?.minByOrNull { it.width * it.height }
wanted = wanted.copy(camera = firstCamera?.id.orEmpty(), width = firstSize?.width ?: 640, height = firstSize?.height ?: 480)
mutable.value = mutable.value.copy(ready = true, cameras = cameras, codecs = encoders.keys.toList(), settings = wanted,
codecSizes = encoders.mapValues { it.value.sizes })
report(if (encoders.isEmpty()) "Receive-only video" else "Video ready")
tick()
} catch (e: Exception) { fail("Video setup: ${e.message}") }
@@ -120,7 +173,10 @@ class CallVideoEngine(private val context: Context, private val id: Long) {
fun detachSurface(surface: Surface) = player.detach(surface)
fun presented(surface: Surface, pts: Long) = player.presented(surface, pts)
fun select(settings: CallVideoSettings) { handler.post {
if (!stopped) { wanted = settings; excluded = emptySet(); mutable.value = mutable.value.copy(settings = settings) }
if (!stopped) {
wanted = settings; excluded = emptySet(); observeOrientation(settings.enabled)
mutable.value = mutable.value.copy(settings = settings, transmitting = settings.enabled && mutable.value.transmitting, notice = null)
}
} }
fun range(value: CallVideoBitrateRange) { handler.post {
if (CallVideoNative.range(id, value.minimum * 1000, value.maximum * 1000)) mutable.value = mutable.value.copy(range = value)
@@ -132,29 +188,30 @@ class CallVideoEngine(private val context: Context, private val id: Long) {
}
private fun fail(message: String) {
LogManager.addLog("ERROR", "call", "video call=$id $message")
wanted = wanted.copy(enabled = false); mutable.value = mutable.value.copy(settings = wanted, status = message)
wanted = wanted.copy(enabled = false); observeOrientation(false)
mutable.value = mutable.value.copy(settings = wanted, status = message, transmitting = false, notice = R.string.call_video_unavailable)
closeCamera(); encoder?.close(); encoder = null; pendingFrames.clear(); active = null
if (confirmingRequest != 0L) CallVideoNative.applied(id, format?.generation ?: 0, confirmingRequest, 0)
confirmingRequest = 0
switching = false; encoderDrained = false
}
private fun tick() {
if (stopped) return
try {
val s = CallVideoNative.status(id) ?: return
check(s[5] == 0L) { "Core rejected video format" }
canEncode = s[10] != 0L
if (orientation.confirm(SystemClock.elapsedRealtime())) logOrientation()
canEncode = s[10] != 0L && s[4] == format?.generation
if (s[6] >= 300000 && s[7] >= s[6]) mutable.value = mutable.value.copy(range = CallVideoBitrateRange((s[6] / 1000).toInt(), (s[7] / 1000).toInt()))
if (keySerial != s[0]) { keySerial = s[0]; needKey = true }
val current = format
if (current != null && s[8] == current.generation && s[9] == 0L && rejectedGeneration != s[8]) {
rejectedGeneration = s[8]; excluded = excluded + current.codec
if (wanted.codec == 0) {
report("Receiver rejected video; selecting fallback")
val peer = CallVideoNative.peer(id)
if (peer != null) change(peer)
} else fail("Receiver cannot decode selected codec")
check(wanted.codec == 0) { "Receiver cannot decode selected codec" }
report("Receiver rejected video; selecting fallback")
}
if (s[1] == format?.generation && s[2] > request) {
if (!encoderDrained && s[1] == format?.generation && s[2] > request) {
request = s[2]; pendingRequest = request; pendingRate = s[3].toInt()
LogManager.addLog("DEBUG", "call", "video bitrate queued id=$id request=$request target=$pendingRate lastKeyRequest=$lastKeyRequest")
}
@@ -163,11 +220,15 @@ class CallVideoEngine(private val context: Context, private val id: Long) {
val pending = if (confirmingRequest != 0L) confirmingRequest else pendingRequest
if (pending != 0L) CallVideoNative.applied(id, format?.generation ?: 0, pending, 0)
confirmingRequest = 0; pendingRequest = 0
closeCamera(); encoder?.close(); encoder = null; pendingFrames.clear(); active = null; report("Video off")
closeCamera(); encoder?.close(); encoder = null; pendingFrames.clear(); active = null
switching = false; encoderDrained = false; report("Video off")
}
} else if (wanted != active && !opening && !closing && (lastKeyRequest < 0 || System.nanoTime() / 1000 - lastKeyRequest >= 1_000_000)) {
val peer = CallVideoNative.peer(id)
if (peer != null) change(peer)
} else {
val turn = rotation(wanted)
val width = if (turn % 180 == 0) wanted.width else wanted.height
val height = if (turn % 180 == 0) wanted.height else wanted.width
switching = switching || wanted != active || current?.width != width || current.height != height || current.codec in excluded
if (switching && !opening && !closing) CallVideoNative.peer(id)?.let { change(it) }
}
drain()
if (active != null && !opening && !closing && lastCaptureAt != 0L
@@ -175,52 +236,80 @@ class CallVideoEngine(private val context: Context, private val id: Long) {
} catch (e: Exception) { fail("Video worker: ${e.message}") }
if (!stopped) handler.postDelayed(::tick, 10)
}
private fun change(peer: ByteArray) {
val cameraInfo = manager.getCameraCharacteristics(wanted.camera)
val rotation = cameraInfo.get(CameraCharacteristics.SENSOR_ORIENTATION) ?: 0
val desired = CallVideoFormat(++generation, wanted.width, wanted.height, rotation = rotation, codec = wanted.codec)
val previous = encoder; val previousFormat = format; val previousSettings = active
val previousName = previous?.name; val previousRate = previous?.bitrate
val range = mutable.value.range
val initialRate = (previousRate ?: 1_000_000).coerceIn(range.minimum * 1000, range.maximum * 1000)
previous?.close(); encoder = null; pendingFrames.clear()
for (codec in if (wanted.codec == 0) listOf(2, 1) else listOf(wanted.codec)) {
/** Проба кандидата не меняет камеру, порт или видимое состояние. */
private fun prepareEncoder(settings: CallVideoSettings, peer: ByteArray, bitrate: Int): CallVideoCodec? {
val turn = rotation(settings)
val width = if (turn % 180 == 0) settings.width else settings.height
val height = if (turn % 180 == 0) settings.height else settings.width
for (codec in if (settings.codec == 0) listOf(2, 1) else listOf(settings.codec)) {
if (codec in excluded) continue
val implementation = encoders[codec] ?: continue
val selected = CallVideoNative.choose(caps, peer, desired.copy(codec = codec).array()) ?: continue
if (Size(settings.width, settings.height) !in implementation.sizes) continue
val desired = CallVideoFormat(++generation, width, height, codec = codec)
val selected = CallVideoNative.choose(caps, peer, desired.array()) ?: continue
var candidate: CallVideoCodec? = null
try {
candidate = CallVideoCodec(CallVideoFormat.from(selected), initialRate, implementation.name)
candidate = CallVideoCodec(CallVideoFormat.from(selected), bitrate, implementation.name)
candidate.prepare()
val next = candidate.format
check(CallVideoNative.supported(caps, peer, next.array())) { "Actual encoder format exceeds peer capabilities: $next" }
check(CallVideoNative.format(id, next.array(), candidate.bitrate)) { "Video format rejected" }
closeCamera(); encoder = candidate; format = next; active = wanted
pendingRequest = 0; confirmingRequest = 0; request = 0; lastKeyRequest = -1; needKey = true
mutable.value = mutable.value.copy(bitrate = candidate.bitrate)
if (!closing) openCamera()
return
check(CallVideoNative.supported(caps, peer, candidate.format.array())) { "Actual encoder format exceeds peer capabilities" }
return candidate
} catch (e: Exception) {
candidate?.close()
LogManager.addLog("WARN", "call", "video mode rejected codec=$codec name=${implementation.name}: ${e.message}")
LogManager.addLog("WARN", "call", "video mode rejected id=$id codec=$codec source=${settings.width}x${settings.height} " +
"output=${width}x$height rotation=$turn name=${implementation.name}: ${e.message}")
}
}
if (previousFormat != null && previousFormat.codec !in excluded && previousSettings != null && previousName != null && previousRate != null) {
encoder = CallVideoCodec(previousFormat, previousRate, previousName)
encoder!!.prepare(); check(encoder!!.format == previousFormat)
wanted = previousSettings; needKey = true
mutable.value = mutable.value.copy(settings = wanted)
report("Selected mode unavailable; previous video restored")
} else fail("No compatible encoder for selected video mode")
return null
}
/** Сначала выводим старые входы и освобождаем mailbox; затем публикуем новое поколение.
* При одном повороте CameraCaptureSession и прямое превью продолжают работать. */
private fun change(peer: ByteArray) {
if (!encoderDrained) {
LogManager.addLog("INFO", "call", "video switch begin id=$id from=$format wanted=$wanted angle=${orientation.degrees}")
drain(finish = true); encoderDrained = true
}
drain()
if (pendingFrames.isNotEmpty() || CallVideoNative.status(id)?.get(11) != 1L) return
val previousSettings = active
val range = mutable.value.range
val bitrate = (encoder?.bitrate ?: 1_000_000).coerceIn(range.minimum * 1000, range.maximum * 1000)
encoder?.close(); encoder = null
val targets = listOfNotNull(wanted, previousSettings).distinct()
for (settings in targets) {
val candidate = prepareEncoder(settings, peer, bitrate) ?: continue
if (stopped) { candidate.close(); return }
try {
check(CallVideoNative.format(id, candidate.format.array(), candidate.bitrate)) { "Video format rejected" }
} catch (e: Exception) { candidate.close(); throw e }
val sourceChanged = previousSettings == null || settings.camera != previousSettings.camera
|| settings.width != previousSettings.width || settings.height != previousSettings.height
if (sourceChanged) closeCamera()
encoder = candidate; format = candidate.format; active = settings; wanted = settings
pendingRequest = 0; confirmingRequest = 0; request = 0; lastKeyRequest = -1; needKey = true
switching = false; encoderDrained = false; appliedRotation = -1; canEncode = false
val restored = settings != targets.first()
mutable.value = mutable.value.copy(bitrate = candidate.bitrate, settings = settings,
notice = if (restored) R.string.call_video_mode_restored else null)
LogManager.addLog("INFO", "call", "video switch committed id=$id format=$format rotation=${rotation(settings)} " +
"cameraRestart=$sourceChanged preview=${preview.state.value.format}")
if (restored) LogManager.addLog("WARN", "call", "video mode unavailable id=$id requested=${targets.first()} restored=$settings")
report(if (restored) "Selected mode unavailable; previous settings restored" else
"${if (candidate.format.codec == 2) "H.265" else "H.264"} · ${candidate.format.width}×${candidate.format.height}")
if (camera == null && !closing && !opening) openCamera()
return
}
fail("No compatible encoder for selected size and orientation")
}
@SuppressLint("MissingPermission")
private fun openCamera() {
if (stopped || !wanted.enabled || opening || closing) return
val settings = active ?: return; val f = format ?: return; val token = ++cameraToken
val settings = active ?: return; val token = ++cameraToken
val c = manager.getCameraCharacteristics(settings.camera)
realtime = c.get(CameraCharacteristics.SENSOR_INFO_TIMESTAMP_SOURCE) == CameraCharacteristics.SENSOR_INFO_TIMESTAMP_SOURCE_REALTIME
sensorOffset = null
val nextReader = ImageReader.newInstance(f.width, f.height, ImageFormat.YUV_420_888, 3); reader = nextReader
val nextReader = ImageReader.newInstance(settings.width, settings.height, ImageFormat.YUV_420_888, 3); reader = nextReader
nextReader.setOnImageAvailableListener({ source ->
val image = try { source.acquireLatestImage() } catch (e: IllegalStateException) {
LogManager.addLog("WARN", "call", "closed camera reader token=$token/$cameraToken: ${e.message}"); null
@@ -237,10 +326,17 @@ class CallVideoEngine(private val context: Context, private val id: Long) {
if (pts <= lastPts) return@setOnImageAvailableListener
drain()
// До восьми принятых входов/готовых выходов: backpressure действует до кодирования.
if (!canEncode || pendingFrames.isNotEmpty() || (encoder?.pendingFrames ?: 0) >= 8)
if (switching || !canEncode || pendingFrames.isNotEmpty() || (encoder?.pendingFrames ?: 0) >= 8)
return@setOnImageAvailableListener
val f = format ?: return@setOnImageAvailableListener
val turn = rotation(settings)
if (f.width != (if (turn % 180 == 0) settings.width else settings.height)
|| f.height != (if (turn % 180 == 0) settings.height else settings.width)) {
switching = true
return@setOnImageAvailableListener
}
// Автоматический IDR кодека не переносит нашу границу: иначе смена bitrate может ждать бесконечно.
val key = needKey || confirmingRequest != 0L || lastKeyRequest < 0 || pts - lastKeyRequest >= 1_000_000
val key = turn != appliedRotation || needKey || confirmingRequest != 0L || lastKeyRequest < 0 || pts - lastKeyRequest >= 1_000_000
if (key && pendingRequest != 0L) {
val requestId = pendingRequest; pendingRequest = 0
val old = checkNotNull(encoder); val oldRate = old.bitrate; val name = old.name
@@ -264,10 +360,21 @@ class CallVideoEngine(private val context: Context, private val id: Long) {
}
}
}
if (encoder?.submit(image, pts, key) == true) {
val transformStarted = System.nanoTime()
if (encoder?.submit(image, pts, key, turn) == true) {
encoder?.let {
inputWaitMaxUs = maxOf(inputWaitMaxUs, it.inputWaitUs)
copyMaxUs = maxOf(copyMaxUs, it.copyUs); queueMaxUs = maxOf(queueMaxUs, it.queueUs)
}
lastPts = pts
if (key) lastKeyRequest = pts
if (appliedRotation != turn) {
LogManager.addLog("INFO", "call", "video pixels rotated id=$id angle=${orientation.degrees} rotation=$turn " +
"source=${settings.width}x${settings.height} output=${f.width}x${f.height} generation=${f.generation} pts=$pts")
appliedRotation = turn
}
}
transformMaxUs = maxOf(transformMaxUs, (System.nanoTime() - transformStarted) / 1000)
drain()
} catch (e: Exception) { fail("Camera encode: ${e.message}") }
finally { image.close() }
@@ -293,7 +400,7 @@ class CallVideoEngine(private val context: Context, private val id: Long) {
/** Прямое превью использует второй output той же камеры; encoder и его PTS не меняются. */
private fun createSession(device: CameraDevice, source: ImageReader, token: Long, withPreview: Boolean = true) {
val f = format ?: return
val settings = active ?: return
val serial = ++sessionSerial
val output = if (withPreview) previewTarget?.retain() else null
var completed = false
@@ -317,11 +424,12 @@ class CallVideoEngine(private val context: Context, private val id: Long) {
var previewFormat: StreamPreviewFormat? = null
if (output != null) {
val sizes = c.get(CameraCharacteristics.SCALER_STREAM_CONFIGURATION_MAP)?.getOutputSizes(SurfaceTexture::class.java).orEmpty()
val size = sizes.filter { it.width <= f.width && it.height <= f.height && it.width * f.height == it.height * f.width }
val size = sizes.filter { it.width <= settings.width && it.height <= settings.height && it.width * settings.height == it.height * settings.width }
.maxByOrNull { it.width * it.height } ?: error("No compatible preview size")
output.texture.setDefaultBufferSize(size.width, size.height)
previewFormat = StreamPreviewFormat(size.width, size.height,
StreamPreviewCamera(f.rotation, c.get(CameraCharacteristics.LENS_FACING) == CameraCharacteristics.LENS_FACING_FRONT), realtime)
StreamPreviewCamera(c.get(CameraCharacteristics.SENSOR_ORIENTATION) ?: 0,
c.get(CameraCharacteristics.LENS_FACING) == CameraCharacteristics.LENS_FACING_FRONT), realtime)
preview.update(previewFormat)
} else if (withPreview) preview.update(null)
val targets = listOfNotNull(source.surface, output?.surface)
@@ -338,7 +446,6 @@ class CallVideoEngine(private val context: Context, private val id: Long) {
s.setRepeatingRequest(request, null, handler)
if (output != null) preview.update(previewFormat, active = true)
LogManager.addLog("INFO", "call", "camera capture id=$id session=$serial range=$range preview=$previewFormat")
report("${if (f.codec == 2) "H.265" else "H.264"} · ${f.width}×${f.height}")
} catch (e: Exception) { s.close(); rejected("Camera request: ${e.message}") }
}
override fun onConfigureFailed(s: CameraCaptureSession) { s.close(); complete(); rejected("Camera session configuration failed") }
@@ -365,13 +472,19 @@ class CallVideoEngine(private val context: Context, private val id: Long) {
if (sent == 0) break
check(sent == 1) { "Video TX rejected encoded frame ${p.metadata[9]}" }
pendingFrames.removeFirst()
if (wanted.enabled && !mutable.value.transmitting) {
mutable.value = mutable.value.copy(transmitting = true)
LogManager.addLog("INFO", "call", "video transmission started id=$id generation=${f.generation} frame=${p.metadata[9]}")
}
}
val now = System.nanoTime()
if (now - reportAt > 1_000_000_000) {
if (reportAt != 0L) LogManager.addLog("DEBUG", "call", "video output id=$id fps=$encodedFrames " +
"bps=${encodedBytes * 8_000_000_000 / (now - reportAt)} target=${codec.bitrate} needKey=$needKey " +
"pending=${pendingFrames.size} codecPending=${codec.pendingFrames} canEncode=$canEncode")
reportAt = now; encodedFrames = 0; encodedBytes = 0
"pending=${pendingFrames.size} codecPending=${codec.pendingFrames} canEncode=$canEncode switching=$switching " +
"submitMaxUs=$transformMaxUs inputWaitMaxUs=$inputWaitMaxUs copyMaxUs=$copyMaxUs queueMaxUs=$queueMaxUs")
reportAt = now; encodedFrames = 0; encodedBytes = 0; transformMaxUs = 0
inputWaitMaxUs = 0; copyMaxUs = 0; queueMaxUs = 0
}
}
private fun closeCamera() {
@@ -386,6 +499,8 @@ class CallVideoEngine(private val context: Context, private val id: Long) {
if (stopped) return
stopped = true; player.stop(); CallVideoNative.close(id)
handler.post {
observeOrientation(false)
mutable.value = mutable.value.copy(transmitting = false)
closeCamera(); encoder?.close(); encoder = null; pendingFrames.clear()
preview.close(); previewTarget?.release(); previewTarget = null
if (camera == null && !opening) thread.quitSafely()
@@ -2,11 +2,15 @@ package com.utun.chat.data
/** Тот же C port, что у desktop: один TX mailbox, ограниченный RX, идентификатор звонка в каждой операции. */
internal object CallVideoNative {
// Вызывать только после успешных encoder/decoder проб в обеих ориентациях.
external fun rotatableCaps(caps: ByteArray): ByteArray?
init { System.loadLibrary("utun") }
external fun open(id: Long, caps: ByteArray): Boolean
external fun close(id: Long)
external fun peer(id: Long): ByteArray?
external fun status(id: Long): LongArray?
external fun rotatePlane(source: java.nio.ByteBuffer, sourceRow: Int, sourcePixel: Int,
destination: java.nio.ByteBuffer, destinationRow: Int, destinationPixel: Int, width: Int, height: Int, rotation: Int): Boolean
external fun choose(local: ByteArray, peer: ByteArray, desired: LongArray): LongArray?
external fun format(id: Long, format: LongArray, bps: Int): Boolean
external fun applied(id: Long, generation: Long, request: Long, bps: Int)
@@ -22,7 +26,7 @@ internal object CallVideoNative {
external fun requestKey(id: Long)
external fun range(id: Long, minimum: Int, maximum: Int): Boolean
external fun inspect(codec: Int, bytes: ByteArray): LongArray?
external fun probeKey(codec: Int, profile: Int): ByteArray?
external fun probeKey(codec: Int, profile: Int, portrait: Boolean): ByteArray?
external fun supported(local: ByteArray, peer: ByteArray, format: LongArray): Boolean
external fun decoded(id: Long, generation: Long, accepted: Boolean)
}
@@ -0,0 +1,29 @@
package com.utun.chat.data
/** Угол OrientationEventListener: 90° означает левое ребро сверху, независимо от экрана.
* Первый достоверный отсчёт принимается сразу; далее 60° гистерезис и 250 мс устойчивости. */
internal class CallVideoOrientation {
var degrees = 0
private set
private var known = false
private var candidate = -1
private var candidateAt = 0L
fun observe(angle: Int, nowMs: Long): Boolean {
if (angle !in 0..359) { candidate = -1; return false }
val next = ((angle + 45) / 90 * 90) % 360
if (!known) { known = true; degrees = next; return true }
val difference = kotlin.math.abs(angle - degrees).let { minOf(it, 360 - it) }
if (next == degrees || difference < 60) candidate = -1
else if (candidate != next) { candidate = next; candidateAt = nowMs }
return false
}
fun confirm(nowMs: Long): Boolean {
if (candidate < 0 || nowMs - candidateAt < 250) return false
degrees = candidate; candidate = -1; return true
}
fun resetPending() { candidate = -1 }
fun rotation(sensor: Int, front: Boolean): Int = (sensor + (if (front) -degrees else degrees) + 360) % 360
}
@@ -14,6 +14,9 @@ import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.layout.*
@@ -27,7 +30,6 @@ import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.Mic
import androidx.compose.material.icons.filled.MicOff
import androidx.compose.material.icons.filled.PhoneInTalk
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.Videocam
import androidx.compose.material.icons.filled.VideocamOff
import androidx.compose.material3.*
@@ -50,6 +52,14 @@ import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.text.PlatformTextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.layout.boundsInRoot
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.positionInRoot
import androidx.compose.ui.unit.toSize
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.content.ContextCompat
@@ -115,7 +125,7 @@ private fun CallControlButton(
}
@Composable
private fun CallVideoButton(video: CallVideoEngine, showLabel: Boolean, requestPermission: () -> Unit) {
private fun CallVideoButton(video: CallVideoEngine, showLabel: Boolean, onOptions: () -> Unit, requestPermission: () -> Unit) {
val state by video.state.collectAsState()
val context = LocalContext.current
CallControlButton(
@@ -124,11 +134,16 @@ private fun CallVideoButton(video: CallVideoEngine, showLabel: Boolean, requestP
label = stringResource(R.string.camera),
active = state.settings.enabled,
showLabel = showLabel,
enabled = state.settings.enabled || (state.ready && state.codecs.isNotEmpty() && state.cameras.isNotEmpty()),
onClick = {
onOptions()
val enabled = !state.settings.enabled
LogManager.addLog("INFO", "call", "video button enabled=$enabled")
if (!enabled || ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED)
val available = state.ready && state.sizes().isNotEmpty()
LogManager.addLog(if (enabled && !available) "WARN" else "INFO", "call",
"video button enabled=$enabled ready=${state.ready} available=$available; options shown")
if (enabled && !available) {
Toast.makeText(context, state.notice ?: if (state.ready) R.string.call_video_unavailable
else R.string.call_video_initializing, Toast.LENGTH_LONG).show()
} else if (!enabled || ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED)
CallController.setVideo(state.settings.copy(enabled = enabled))
else requestPermission()
}
@@ -157,15 +172,23 @@ fun CallScreen(
val videoActive by CallController.videoActive.collectAsState()
val remote = video?.display?.collectAsState()?.value
val local = video?.preview?.state?.collectAsState()?.value
val videoState = video?.state?.collectAsState()?.value
val transmitting = phase == CallPhase.ACTIVE && videoState?.transmitting == true
val receiving = phase == CallPhase.ACTIVE && remote != null
val sending = phase == CallPhase.ACTIVE && local?.active == true
var selectedVideo by rememberSaveable(st?.callId) { mutableStateOf<CallVideoSource?>(CallVideoSource.REMOTE) }
var selectedVideo by rememberSaveable(st?.callId) { mutableStateOf<CallVideoSource?>(null) }
val expandedVideo = selectedVideo?.takeIf { if (it == CallVideoSource.REMOTE) receiving else sending }
val viewingVideo = expandedVideo != null
val hasVideo = receiving || sending
val hasVideo = receiving || sending || transmitting
val controlInsets = WindowInsets.systemBarsIgnoringVisibility.union(WindowInsets.displayCutout)
var controlsVisible by rememberSaveable(st?.callId) { mutableStateOf(true) }
var settingsVisible by rememberSaveable(st?.callId) { mutableStateOf(false) }
var settingsVisible by remember(st?.callId) { mutableStateOf(false) }
var videoOptionsRequested by rememberSaveable(st?.callId) { mutableStateOf(false) }
var videoSlot by remember { mutableStateOf(Rect.Zero) }
var rootPosition by remember { mutableStateOf(Offset.Zero) }
var headerBottom by remember { mutableStateOf(0f) }
val density = LocalDensity.current
LaunchedEffect(transmitting) { if (transmitting) videoOptionsRequested = true }
LaunchedEffect(expandedVideo) {
controlsVisible = true
LogManager.addLog("INFO", "call", "video presentation=${expandedVideo ?: "controls"} remote=$receiving local=$sending")
@@ -244,37 +267,43 @@ fun CallScreen(
}
Box(
modifier = Modifier.fillMaxSize().background(Color(0xFF0F1B22)),
modifier = Modifier.fillMaxSize().background(Color(0xFF0F1B22)).onGloballyPositioned { rootPosition = it.positionInRoot() },
contentAlignment = Alignment.Center
) {
if (phase == CallPhase.ACTIVE) video?.let {
CallVideoStage(it, expandedVideo, onSelect = { source -> selectedVideo = source },
CallVideoStage(it, expandedVideo, videoSlot, headerBottom, onSelect = { source ->
videoOptionsRequested = true; selectedVideo = source
},
onTap = { controlsVisible = !controlsVisible }, modifier = Modifier.matchParentSize())
}
if (!viewingVideo || controlsVisible) {
Column(
modifier = Modifier.fillMaxWidth().align(if (viewingVideo) Alignment.BottomCenter else Alignment.Center)
.windowInsetsPadding(controlInsets)
.padding(top = if (viewingVideo) 0.dp else if (hasVideo) 224.dp else 56.dp)
.background(Color(0xFF0F1B22).copy(alpha = if (viewingVideo) 0.75f else 1f)).verticalScroll(rememberScrollState())
.padding(bottom = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally
modifier = Modifier.then(if (viewingVideo) Modifier.fillMaxWidth().align(Alignment.BottomCenter)
.background(Color(0xFF0F1B22).copy(alpha = 0.75f)) else Modifier.fillMaxSize()
.padding(top = with(density) { headerBottom.toDp() }))
.windowInsetsPadding(controlInsets.only(WindowInsetsSides.Horizontal + WindowInsetsSides.Bottom))
.verticalScroll(rememberScrollState()).padding(vertical = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
if (!viewingVideo) {
Box(
modifier = Modifier.size(96.dp).background(Color(0xFF1F6FEB), CircleShape),
contentAlignment = Alignment.Center
) {
Text(
name.take(1).uppercase(),
fontSize = 40.sp,
color = Color.White,
fontWeight = FontWeight.Bold
)
Box(Modifier.fillMaxWidth().height(if (hasVideo) 144.dp else 96.dp)
.onGloballyPositioned { videoSlot = Rect(it.positionInRoot() - rootPosition, it.size.toSize()) },
contentAlignment = Alignment.Center) {
if (!hasVideo) {
Box(Modifier.size(96.dp).background(Color(0xFF1F6FEB), CircleShape), contentAlignment = Alignment.Center) {
Text(name.take(1).uppercase(), fontSize = 40.sp, color = Color.White, fontWeight = FontWeight.Bold)
}
} else Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
for (source in CallVideoSource.entries) {
val visible = if (source == CallVideoSource.LOCAL) sending || transmitting else receiving
val description = stringResource(if (source == CallVideoSource.LOCAL) R.string.call_video_local else R.string.call_video_remote)
if (visible) Box(Modifier.size(104.dp, 144.dp).semantics { contentDescription = description }
.clickable { videoOptionsRequested = true; selectedVideo = source })
}
}
}
Spacer(Modifier.height(24.dp))
Text(name, color = Color.White, fontSize = 24.sp, fontWeight = FontWeight.Medium)
Spacer(Modifier.height(8.dp))
Spacer(Modifier.height(16.dp))
Text(
if (st?.preparing == true) stringResource(R.string.call_preparing_audio)
else if (phase == CallPhase.ACTIVE) fmtDuration(duration) else callStatusText(phase, st?.reason ?: -1),
@@ -306,6 +335,10 @@ fun CallScreen(
}
}
}
videoState?.notice?.takeIf { !videoOptionsRequested && !transmitting }?.let {
Text(stringResource(it), color = Color(0xFFFFB4AB), fontSize = 13.sp,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), textAlign = TextAlign.Center)
}
Spacer(Modifier.height(40.dp))
}
@@ -390,46 +423,36 @@ fun CallScreen(
}
if (phase == CallPhase.ACTIVE) {
video?.let { engine ->
CallVideoButton(engine, showLabel = !viewingVideo, requestPermission = {
CallVideoButton(engine, showLabel = !viewingVideo, onOptions = {
videoOptionsRequested = true; controlsVisible = true
}, requestPermission = {
permissionVideo = engine
cameraPermissionLauncher.launch(Manifest.permission.CAMERA)
})
}
CallControlButton(
icon = Icons.Filled.Settings,
contentDescription = stringResource(R.string.call_video_settings),
label = stringResource(R.string.settings),
active = false,
showLabel = !viewingVideo,
onClick = {
LogManager.addLog("DEBUG", "call", "video settings opened")
settingsVisible = true
}
)
}
}
}
}
IconButton(
onClick = { if (viewingVideo) selectedVideo = null else onMinimize() },
modifier = Modifier
.align(Alignment.TopStart)
.windowInsetsPadding(controlInsets)
.padding(8.dp)
) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, stringResource(if (viewingVideo) R.string.call_controls else R.string.minimize_call),
tint = Color.White, modifier = Modifier.size(24.dp))
}
}
if (settingsVisible && phase == CallPhase.ACTIVE && !locked) AlertDialog(
onDismissRequest = { settingsVisible = false }, title = { Text(stringResource(R.string.call_video_settings)) },
confirmButton = { TextButton(onClick = { settingsVisible = false }) { Text(stringResource(R.string.close)) } },
text = {
Column(Modifier.verticalScroll(rememberScrollState())) {
video?.let { CallVideoPanel(it) }
Column(Modifier.align(Alignment.TopCenter).fillMaxWidth()
.background(Color(0xFF0F1B22).copy(alpha = if (viewingVideo) 0.85f else 1f))
.windowInsetsPadding(controlInsets.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal))
.onGloballyPositioned { headerBottom = it.boundsInRoot().bottom - rootPosition.y }) {
Row(Modifier.fillMaxWidth().height(56.dp).padding(horizontal = 8.dp), verticalAlignment = Alignment.CenterVertically) {
IconButton(onClick = { if (viewingVideo) selectedVideo = null else onMinimize() }) {
Icon(Icons.AutoMirrored.Filled.ArrowBack,
stringResource(if (viewingVideo) R.string.call_controls else R.string.minimize_call), tint = Color.White)
}
})
Text(name, modifier = Modifier.weight(1f).padding(start = 8.dp), color = Color.White,
fontSize = 18.sp, fontWeight = FontWeight.Medium, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
if (phase == CallPhase.ACTIVE && (videoOptionsRequested || transmitting) && !locked) video?.let {
CallVideoToolbar(it, onInteraction = { settingsVisible = it }, modifier = Modifier.fillMaxWidth()
.padding(start = 16.dp, end = 16.dp, bottom = 8.dp))
}
}
}
if (locked) {
Box(
@@ -6,47 +6,122 @@ import android.graphics.SurfaceTexture
import android.os.Build
import android.view.Surface
import android.view.TextureView
import androidx.compose.foundation.background
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.Alignment
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.IntOffset
import kotlin.math.roundToInt
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex
import androidx.compose.ui.viewinterop.AndroidView
import com.utun.chat.data.LogManager
import com.utun.chat.data.CallController
import com.utun.chat.data.CallVideoBitrateRange
import com.utun.chat.data.CallVideoEngine
import com.utun.chat.data.CallVideoFormat
import com.utun.chat.R
private enum class CallVideoOption { CAMERA, CODEC, BITRATE, RESOLUTION }
/** Настройки собственной передачи; приём и прямое превью не управляют доступностью панели. */
@Composable
internal fun CallVideoPanel(video: CallVideoEngine) {
internal fun CallVideoToolbar(video: CallVideoEngine, onInteraction: (Boolean) -> Unit, modifier: Modifier = Modifier) {
val state by video.state.collectAsState()
Column(Modifier.fillMaxWidth().padding(horizontal = 16.dp)) {
if (state.status.isNotEmpty()) Text(state.status)
VideoChoice(stringResource(R.string.camera), state.cameras.map { it.name }, state.cameras.indexOfFirst { it.id == state.settings.camera }) { index ->
val camera = state.cameras[index]
val size = camera.sizes.firstOrNull { it.width == state.settings.width && it.height == state.settings.height } ?: camera.sizes.first()
CallController.setVideo(state.settings.copy(camera = camera.id, width = size.width, height = size.height))
var menu by remember(video) { mutableStateOf<CallVideoOption?>(null) }
var dragging by remember(video) { mutableStateOf(false) }
var selected by remember(video) { mutableStateOf(state.range.minimum.toFloat()..state.range.maximum.toFloat()) }
LaunchedEffect(state.range) { if (!dragging) selected = state.range.minimum.toFloat()..state.range.maximum.toFloat() }
val interaction by rememberUpdatedState(onInteraction)
LaunchedEffect(menu, dragging) {
interaction(menu != null || dragging)
LogManager.addLog("DEBUG", "call", "video toolbar menu=$menu dragging=$dragging")
}
DisposableEffect(video) { onDispose { interaction(false) } }
val camera = state.cameras.firstOrNull { it.id == state.settings.camera }
val cameraLabel = stringResource(R.string.camera)
val codecLabel = stringResource(R.string.call_video_codec)
val resolutionLabel = stringResource(R.string.call_video_resolution)
val codecName = when (state.settings.codec) { 0 -> stringResource(R.string.call_video_auto); 2 -> "H.265"; else -> "H.264" }
Column(modifier, verticalArrangement = Arrangement.spacedBy(4.dp)) {
Row(Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()), horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically) {
Box {
CaptureControlButton("$cameraLabel: ${camera?.name.orEmpty()}",
enabled = state.ready && state.cameras.any { state.sizes(it.id).isNotEmpty() }, onClick = { menu = CallVideoOption.CAMERA }) {
CaptureValueIcon(CaptureIconShape.CAMERA, camera?.id ?: "—", front = camera?.front == true)
}
DropdownMenu(menu == CallVideoOption.CAMERA, onDismissRequest = { menu = null }) {
state.cameras.filter { state.sizes(it.id).isNotEmpty() }.forEach { option ->
CaptureOptionItem(option.name, option.id == state.settings.camera, onClick = {
menu = null
val sizes = state.sizes(option.id)
val size = sizes.firstOrNull { it.width == state.settings.width && it.height == state.settings.height } ?: sizes.first()
LogManager.addLog("INFO", "call", "video toolbar camera=${option.id} size=$size")
CallController.setVideo(state.settings.copy(camera = option.id, width = size.width, height = size.height))
})
}
}
}
Box {
CaptureControlButton("$codecLabel: $codecName", enabled = state.ready && state.codecs.isNotEmpty(),
onClick = { menu = CallVideoOption.CODEC }) {
CaptureValueIcon(CaptureIconShape.CHIP, codecName)
}
DropdownMenu(menu == CallVideoOption.CODEC, onDismissRequest = { menu = null }) {
for (codec in listOf(0) + state.codecs.sortedDescending()) {
val label = when (codec) { 0 -> stringResource(R.string.call_video_auto); 2 -> "H.265"; else -> "H.264" }
val sizes = state.sizes(codec = codec)
if (sizes.isNotEmpty()) CaptureOptionItem(label, codec == state.settings.codec, onClick = {
menu = null
val size = sizes.firstOrNull { it.width == state.settings.width && it.height == state.settings.height } ?: sizes.first()
LogManager.addLog("INFO", "call", "video toolbar codec=$codec size=$size")
CallController.setVideo(state.settings.copy(codec = codec, width = size.width, height = size.height))
})
}
}
}
Box {
val description = stringResource(R.string.call_video_rate, state.range.minimum, state.range.maximum, state.bitrate / 1000)
CaptureControlButton(description, enabled = state.ready, onClick = { menu = CallVideoOption.BITRATE }) {
CaptureValueIcon(CaptureIconShape.GAUGE, "${state.bitrate / 1000}k", detail = "${state.range.minimum}–${state.range.maximum}")
}
DropdownMenu(menu == CallVideoOption.BITRATE, onDismissRequest = { menu = null; dragging = false }) {
Column(Modifier.width(300.dp).padding(horizontal = 16.dp, vertical = 8.dp)) {
Text(stringResource(R.string.call_video_rate, selected.start.toInt(), selected.endInclusive.toInt(), state.bitrate / 1000))
RangeSlider(value = selected, onValueChange = { dragging = true; selected = it }, valueRange = 300f..4000f,
onValueChangeFinished = {
dragging = false
video.range(CallVideoBitrateRange(selected.start.toInt(), selected.endInclusive.toInt()))
})
}
}
}
Box {
CaptureControlButton("$resolutionLabel: ${state.settings.width}×${state.settings.height}",
enabled = state.ready && state.sizes().isNotEmpty(), onClick = { menu = CallVideoOption.RESOLUTION }) {
CaptureValueIcon(CaptureIconShape.SCREEN, "${minOf(state.settings.width, state.settings.height)}p", detail = "30fps")
}
DropdownMenu(menu == CallVideoOption.RESOLUTION, onDismissRequest = { menu = null }) {
state.sizes().forEach { size -> CaptureOptionItem("${size.width}×${size.height}",
size.width == state.settings.width && size.height == state.settings.height, onClick = {
menu = null
LogManager.addLog("INFO", "call", "video toolbar resolution=$size")
CallController.setVideo(state.settings.copy(width = size.width, height = size.height))
}) }
}
}
}
val sizes = state.cameras.firstOrNull { it.id == state.settings.camera }?.sizes.orEmpty()
VideoChoice(stringResource(R.string.call_video_resolution), sizes.map { "${it.width}×${it.height}" }, sizes.indexOfFirst { it.width == state.settings.width && it.height == state.settings.height }) { i ->
CallController.setVideo(state.settings.copy(width = sizes[i].width, height = sizes[i].height))
}
val codecs = listOf(0) + state.codecs.sortedDescending()
VideoChoice(stringResource(R.string.call_video_codec), codecs.map { when (it) { 0 -> stringResource(R.string.call_video_auto); 2 -> "H.265"; else -> "H.264" } },
codecs.indexOf(state.settings.codec)) { i -> CallController.setVideo(state.settings.copy(codec = codecs[i])) }
var selected by remember(video) { mutableStateOf(state.range.minimum.toFloat()..state.range.maximum.toFloat()) }
var dragging by remember(video) { mutableStateOf(false) }
LaunchedEffect(state.range) { if (!dragging) selected = state.range.minimum.toFloat()..state.range.maximum.toFloat() }
Text(stringResource(R.string.call_video_rate, selected.start.toInt(), selected.endInclusive.toInt(), state.bitrate / 1000))
RangeSlider(value = selected, onValueChange = { dragging = true; selected = it }, valueRange = 300f..4000f,
onValueChangeFinished = { dragging = false; video.range(CallVideoBitrateRange(selected.start.toInt(), selected.endInclusive.toInt())) })
val notice = state.notice ?: if (!state.ready) R.string.call_video_initializing
else if (state.sizes().isEmpty()) R.string.call_video_unavailable else null
notice?.let { Text(stringResource(it), color = Color(0xFFFFB4AB), style = MaterialTheme.typography.bodySmall) }
}
}
@@ -55,37 +130,46 @@ internal enum class CallVideoSource { LOCAL, REMOTE }
/** Один TextureView меняет только геометрию: переход в миниатюру не переподключает decoder. */
@Composable
@OptIn(ExperimentalLayoutApi::class)
internal fun CallVideoStage(video: CallVideoEngine, expanded: CallVideoSource?, onSelect: (CallVideoSource) -> Unit,
onTap: () -> Unit, modifier: Modifier = Modifier) {
internal fun CallVideoStage(video: CallVideoEngine, expanded: CallVideoSource?, slot: Rect, headerBottom: Float,
onSelect: (CallVideoSource) -> Unit, onTap: () -> Unit, modifier: Modifier = Modifier) {
val remote by video.display.collectAsState()
val local by video.preview.state.collectAsState()
val state by video.state.collectAsState()
val displayDegrees = StreamDisplayDegrees()
val insets = WindowInsets.systemBarsIgnoringVisibility.union(WindowInsets.displayCutout)
Box(modifier) {
// Слоты не меняются местами в composition; миниатюра рисуется поверх большого кадра.
val density = LocalDensity.current
val localVisible = local.active || state.transmitting
BoxWithConstraints(modifier) {
val smallWidth = minOf(104.dp, maxWidth / 2 - 12.dp)
val smallHeight = 144.dp
val widthPx = with(density) { smallWidth.toPx() }
val gapPx = with(density) { 12.dp.toPx() }
val count = (if (localVisible) 1 else 0) + (if (remote != null) 1 else 0)
val centerX = if (slot.width > 0) slot.center.x else with(density) { maxWidth.toPx() } / 2
// Обе поверхности живут до конца видеосессии, даже пока аватар скрывает пустой слот.
for (source in CallVideoSource.entries) key(source) {
val large = expanded == source
Box(Modifier.matchParentSize().zIndex(if (large) 0f else 1f)
.then(if (large) Modifier else Modifier.windowInsetsPadding(insets).padding(top = 64.dp, end = 12.dp))) {
val index = if (source == CallVideoSource.REMOTE && localVisible) 1 else 0
val x = if (expanded != null) with(density) { maxWidth.toPx() } - widthPx - gapPx
else centerX - (maxOf(count, 1) * widthPx + maxOf(count - 1, 0) * gapPx) / 2 + index * (widthPx + gapPx)
val y = if (expanded != null) headerBottom + gapPx else slot.top
Box(Modifier.matchParentSize().zIndex(if (large) 0f else 1f)) {
val frameModifier = if (large) Modifier.matchParentSize() else Modifier
.align(if (expanded != null) Alignment.TopEnd else Alignment.TopCenter)
.offset(x = if (expanded != null || !local.active || remote == null) 0.dp
else if (source == CallVideoSource.LOCAL) (-60).dp else 60.dp)
.size(104.dp, 144.dp)
.offset { IntOffset(x.roundToInt(), y.roundToInt()) }.size(smallWidth, smallHeight)
val description = stringResource(if (source == CallVideoSource.LOCAL) R.string.call_video_local else R.string.call_video_remote)
val click = { if (large) onTap() else onSelect(source) }
if (source == CallVideoSource.REMOTE) {
CallVideoPlayback(video, click, frameModifier, description, large)
} else BoxWithConstraints(frameModifier, contentAlignment = Alignment.Center) {
val aspect = local.format?.aspect(displayDegrees) ?: 1f
val turn = large && minOf(maxHeight.value, maxWidth.value * aspect) > minOf(maxWidth.value, maxHeight.value * aspect)
val width = if (turn) minOf(maxHeight, maxWidth * aspect) else minOf(maxWidth, maxHeight * aspect)
val width = minOf(maxWidth, maxHeight * aspect)
AndroidView(factory = { CameraPreviewView(it, video.preview, keepAwake = false) },
modifier = Modifier.requiredSize(width, width / aspect).graphicsLayer { rotationZ = if (turn) 90f else 0f }, update = { view ->
modifier = Modifier.requiredSize(width, width / aspect), update = { view ->
view.update(local.format, displayDegrees); view.alpha = if (local.active) 1f else 0f
view.contentDescription = description
view.setOnClickListener { if (local.active) click() }; view.isClickable = local.active
}, onRelease = { it.close() })
if (localVisible && !local.active) Text(stringResource(if (local.failed) R.string.stream_preview_failed
else R.string.stream_connecting), color = Color.White, modifier = Modifier.padding(8.dp))
}
}
}
@@ -141,14 +225,10 @@ private class CallVideoTexture(context: Context, private val video: CallVideoEng
val rotated = f.rotation % 180 != 0
val w = if (rotated) f.height else f.width; val h = if (rotated) f.width else f.height
val scale = minOf(width.toFloat() / w, height.toFloat() / h)
val turnedScale = minOf(width.toFloat() / h, height.toFloat() / w)
val turn = large && turnedScale > scale
val fitted = if (turn) turnedScale else scale
setTransform(Matrix().apply {
setScale(w * fitted / width, h * fitted / height, width / 2f, height / 2f)
if (turn) postRotate(90f, width / 2f, height / 2f)
setScale(w * scale / width, h * scale / height, width / 2f, height / 2f)
})
com.utun.chat.data.LogManager.addLog("DEBUG", "call", "video fit view=${width}x$height frame=${w}x$h expanded=$large turn=$turn")
com.utun.chat.data.LogManager.addLog("DEBUG", "call", "video fit view=${width}x$height frame=${w}x$h expanded=$large")
}
private fun closeOutput() { attached?.let(video::detachSurface); attached = null }
fun close() {
@@ -156,14 +236,3 @@ private class CallVideoTexture(context: Context, private val video: CallVideoEng
closeOutput()
}
}
@Composable
private fun VideoChoice(label: String, values: List<String>, selected: Int, choose: (Int) -> Unit) {
var expanded by remember { mutableStateOf(false) }
Box {
TextButton(onClick = { expanded = true }, enabled = values.isNotEmpty()) { Text("$label: ${values.getOrNull(selected).orEmpty()}") }
DropdownMenu(expanded, onDismissRequest = { expanded = false }) {
values.forEachIndexed { index, value -> DropdownMenuItem(text = { Text(value) }, onClick = { expanded = false; choose(index) }) }
}
}
}
@@ -0,0 +1,130 @@
package com.utun.chat.ui.screens
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.outlined.Person
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.selected
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
internal enum class CaptureIconShape { CAMERA, CHIP, GAUGE, SCREEN }
/** Единый размер касания, подсказки и семантика для обычных кнопок и переключателя звука. */
@OptIn(ExperimentalMaterial3Api::class)
@Composable
internal fun CaptureControlButton(description: String, enabled: Boolean = true, checked: Boolean? = null,
containerColor: Color? = null, onClick: () -> Unit, icon: @Composable () -> Unit) {
TooltipBox(positionProvider = TooltipDefaults.rememberPlainTooltipPositionProvider(), tooltip = { PlainTooltip { Text(description) } },
state = rememberTooltipState()) {
val modifier = Modifier.size(48.dp).semantics { contentDescription = description }
val colors = MaterialTheme.colorScheme
val background = containerColor ?: colors.surfaceVariant.copy(alpha = 0.55f)
val disabledBackground = containerColor?.copy(alpha = 0.38f) ?: colors.surfaceVariant.copy(alpha = 0.2f)
val content = if (containerColor != null) Color.White else colors.onSurface
val shape = RoundedCornerShape(12.dp)
if (checked == null) FilledTonalIconButton(onClick = onClick, enabled = enabled, modifier = modifier, shape = shape,
colors = IconButtonDefaults.filledTonalIconButtonColors(containerColor = background, contentColor = content,
disabledContainerColor = disabledBackground, disabledContentColor = content.copy(alpha = 0.38f))) {
Box(Modifier.clearAndSetSemantics {}) { icon() }
} else FilledTonalIconToggleButton(checked = checked, onCheckedChange = { onClick() }, enabled = enabled,
modifier = modifier, shape = shape,
colors = IconButtonDefaults.filledTonalIconToggleButtonColors(containerColor = background, contentColor = colors.onSurfaceVariant,
disabledContainerColor = disabledBackground, checkedContainerColor = colors.primaryContainer,
checkedContentColor = colors.onPrimaryContainer)) {
Box(Modifier.clearAndSetSemantics {}) { icon() }
}
}
}
/** Различимые силуэты камеры, микросхемы, шкалы скорости и экрана содержат текущие значения. */
@Composable
internal fun CaptureValueIcon(shape: CaptureIconShape, value: String, detail: String? = null, front: Boolean = false) {
val frame = LocalContentColor.current
val accent = MaterialTheme.colorScheme.primary.copy(alpha = frame.alpha)
Box(Modifier.size(40.dp, 36.dp), contentAlignment = Alignment.Center) {
Canvas(Modifier.matchParentSize()) {
val w = size.width; val h = size.height
val stroke = Stroke(1.5.dp.toPx(), cap = StrokeCap.Round)
when (shape) {
CaptureIconShape.CAMERA -> {
val body = Path().apply {
moveTo(w * 0.12f, h * 0.24f); lineTo(w * 0.28f, h * 0.24f)
lineTo(w * 0.36f, h * 0.08f); lineTo(w * 0.64f, h * 0.08f); lineTo(w * 0.72f, h * 0.24f)
lineTo(w * 0.88f, h * 0.24f); quadraticBezierTo(w * 0.96f, h * 0.24f, w * 0.96f, h * 0.34f)
lineTo(w * 0.96f, h * 0.84f); quadraticBezierTo(w * 0.96f, h * 0.94f, w * 0.88f, h * 0.94f)
lineTo(w * 0.12f, h * 0.94f); quadraticBezierTo(w * 0.04f, h * 0.94f, w * 0.04f, h * 0.84f)
lineTo(w * 0.04f, h * 0.34f); quadraticBezierTo(w * 0.04f, h * 0.24f, w * 0.12f, h * 0.24f); close()
}
drawPath(body, frame, style = stroke)
drawLine(accent, Offset(w * 0.43f, h * 0.08f), Offset(w * 0.57f, h * 0.08f), stroke.width, StrokeCap.Round)
}
CaptureIconShape.CHIP -> {
drawRoundRect(frame, Offset(w * 0.14f, h * 0.14f), Size(w * 0.72f, h * 0.72f),
CornerRadius(3.dp.toPx()), style = stroke)
for (position in listOf(0.28f, 0.5f, 0.72f)) {
drawLine(accent, Offset(w * position, h * 0.02f), Offset(w * position, h * 0.14f), stroke.width)
drawLine(accent, Offset(w * position, h * 0.86f), Offset(w * position, h * 0.98f), stroke.width)
drawLine(accent, Offset(w * 0.02f, h * position), Offset(w * 0.14f, h * position), stroke.width)
drawLine(accent, Offset(w * 0.86f, h * position), Offset(w * 0.98f, h * position), stroke.width)
}
}
CaptureIconShape.GAUGE -> {
drawArc(accent, 180f, 180f, false, Offset(w * 0.05f, h * 0.06f), Size(w * 0.9f, h * 0.9f), style = stroke)
drawLine(frame, Offset(w * 0.05f, h * 0.51f), Offset(w * 0.15f, h * 0.51f), stroke.width)
drawLine(frame, Offset(w * 0.5f, h * 0.06f), Offset(w * 0.5f, h * 0.16f), stroke.width)
drawLine(frame, Offset(w * 0.85f, h * 0.51f), Offset(w * 0.95f, h * 0.51f), stroke.width)
drawLine(frame, Offset(w * 0.16f, h * 0.94f), Offset(w * 0.84f, h * 0.94f), stroke.width, StrokeCap.Round)
}
CaptureIconShape.SCREEN -> {
drawRoundRect(frame, Offset(w * 0.03f, h * 0.05f), Size(w * 0.94f, h * 0.76f),
CornerRadius(3.dp.toPx()), style = stroke)
drawLine(accent, Offset(w * 0.5f, h * 0.81f), Offset(w * 0.5f, h * 0.96f), stroke.width)
drawLine(accent, Offset(w * 0.3f, h * 0.96f), Offset(w * 0.7f, h * 0.96f), stroke.width, StrokeCap.Round)
}
}
}
when (shape) {
CaptureIconShape.CAMERA -> Box(Modifier.offset(y = 4.dp)) {
if (front) Icon(Icons.Outlined.Person, contentDescription = null, modifier = Modifier.size(24.dp))
else Text(value, fontSize = 11.sp, lineHeight = 13.sp, fontWeight = FontWeight.Medium, maxLines = 1, softWrap = false)
}
CaptureIconShape.CHIP -> Text(value, fontSize = 11.sp, lineHeight = 13.sp,
fontWeight = FontWeight.SemiBold, maxLines = 1, softWrap = false)
CaptureIconShape.GAUGE -> {
if (detail != null) Text(detail, Modifier.align(Alignment.TopCenter).padding(top = 7.dp),
fontSize = 8.sp, lineHeight = 9.sp, fontWeight = FontWeight.Medium, color = accent, maxLines = 1)
Text(value, Modifier.offset(y = 7.dp), fontSize = 12.sp, lineHeight = 14.sp,
fontWeight = FontWeight.SemiBold, maxLines = 1, softWrap = false)
}
CaptureIconShape.SCREEN -> Column(Modifier.padding(bottom = 5.dp), horizontalAlignment = Alignment.CenterHorizontally) {
Text(value, fontSize = 11.sp, lineHeight = 12.sp, fontWeight = FontWeight.SemiBold, maxLines = 1, softWrap = false)
if (detail != null) Text(detail, fontSize = 8.sp, lineHeight = 9.sp, maxLines = 1, softWrap = false)
}
}
}
}
/** Текущее значение отмечено галочкой и доступно средствам чтения экрана. */
@Composable
internal fun CaptureOptionItem(label: String, active: Boolean, onClick: () -> Unit, icon: @Composable (() -> Unit)? = null) {
DropdownMenuItem(text = { Text(label) }, onClick = onClick, leadingIcon = icon,
trailingIcon = { if (active) Icon(Icons.Filled.Check, contentDescription = null) }, modifier = Modifier.semantics { selected = active })
}
@@ -23,10 +23,8 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.VolumeOff
import androidx.compose.material.icons.automirrored.filled.VolumeUp
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Stop
import androidx.compose.material.icons.outlined.Person
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
@@ -34,15 +32,10 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.drawscope.rotate
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.selected
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalLifecycleOwner
@@ -163,7 +156,7 @@ fun StreamScreen(channelId: String, channelName: String, sources: List<StreamSou
})
val action = if (ongoing == null) R.string.stream_start
else if (ongoing.phase == StreamPhase.STOPPING) R.string.stream_stopping else R.string.stream_stop
StreamControlButton(stringResource(action),
CaptureControlButton(stringResource(action),
enabled = if (ongoing != null) ongoing.phase != StreamPhase.STOPPING
else !loadingModes && config != null && group != 0L && state?.ongoing != true,
containerColor = if (ongoing != null) Color(0xFFB32632) else Color(0xFF24633D), onClick = {
@@ -229,7 +222,6 @@ private fun StreamTxPanel(id: Long) {
}
private enum class StreamOption { CAMERA, CODEC, BITRATE, RESOLUTION, ORIENTATION }
private enum class StreamIconShape { CAMERA, CHIP, GAUGE, SCREEN }
/** Одна строка управления: значки отражают применённый режим, меню сохраняют все варианты выбора. */
@Composable
@@ -257,23 +249,23 @@ private fun StreamCaptureToolbar(cameras: List<StreamCamera>, config: StreamCapt
stringResource(R.string.stream_bitrate_auto_value, bitrateValue) else stringResource(R.string.stream_bitrate, bitrateValue)
Row(modifier.horizontalScroll(rememberScrollState()), horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically) {
StreamControlButton(stringResource(if (audio) R.string.stream_audio_on else R.string.stream_audio_off),
CaptureControlButton(stringResource(if (audio) R.string.stream_audio_on else R.string.stream_audio_off),
enabled = audioEnabled, checked = audio, onClick = { onAudioChange(!audio) }) {
Icon(if (audio) Icons.AutoMirrored.Filled.VolumeUp else Icons.AutoMirrored.Filled.VolumeOff,
contentDescription = null, modifier = Modifier.size(32.dp))
}
Box {
val name = camera?.let { "${it.lens.label} ${it.lens.mm}".trim() } ?: "—"
StreamControlButton(stringResource(R.string.stream_camera, name), enabled && cameras.isNotEmpty(),
CaptureControlButton(stringResource(R.string.stream_camera, name), enabled && cameras.isNotEmpty(),
onClick = { menu = StreamOption.CAMERA }) {
val focal = camera?.lens?.eqFocal?.takeIf { it > 0 }?.let {
stringResource(R.string.stream_camera_focal_short, it.roundToInt())
} ?: "—"
StreamValueIcon(StreamIconShape.CAMERA, focal, front = camera?.lens?.type == LensType.SELFIE)
CaptureValueIcon(CaptureIconShape.CAMERA, focal, front = camera?.lens?.type == LensType.SELFIE)
}
DropdownMenu(expanded = menu == StreamOption.CAMERA && enabled, onDismissRequest = { menu = null }) {
cameras.forEach { option ->
StreamOptionItem("${option.lens.label} ${option.lens.mm}".trim(), option.id == mode?.camera, onClick = {
CaptureOptionItem("${option.lens.label} ${option.lens.mm}".trim(), option.id == mode?.camera, onClick = {
menu = null
val next = StreamCameraModes.choose(option.modes, mode)
if (next != null) onChange(config?.withMode(next) ?: StreamCaptureConfig(next))
@@ -283,13 +275,13 @@ private fun StreamCaptureToolbar(cameras: List<StreamCamera>, config: StreamCapt
}
}
Box {
StreamControlButton(stringResource(R.string.stream_video_codec, mode?.videoCodec?.label ?: "—"),
CaptureControlButton(stringResource(R.string.stream_video_codec, mode?.videoCodec?.label ?: "—"),
enabled && config != null, onClick = { menu = StreamOption.CODEC }) {
StreamValueIcon(StreamIconShape.CHIP, mode?.videoCodec?.name ?: "—")
CaptureValueIcon(CaptureIconShape.CHIP, mode?.videoCodec?.name ?: "—")
}
DropdownMenu(expanded = menu == StreamOption.CODEC && enabled, onDismissRequest = { menu = null }) {
allModes.map { it.videoCodec }.distinct().sortedBy { it.id }.forEach { codec ->
StreamOptionItem(codec.label, codec == mode?.videoCodec, onClick = {
CaptureOptionItem(codec.label, codec == mode?.videoCodec, onClick = {
menu = null
val next = StreamCameraModes.choose(allModes.filter { it.videoCodec == codec }, mode)
if (next != null) config?.let { onChange(it.withMode(next)) }
@@ -299,18 +291,18 @@ private fun StreamCaptureToolbar(cameras: List<StreamCamera>, config: StreamCapt
}
}
Box {
StreamControlButton(bitrateDescription, enabled && config != null, onClick = { menu = StreamOption.BITRATE }) {
StreamValueIcon(StreamIconShape.GAUGE, shortBitrate,
CaptureControlButton(bitrateDescription, enabled && config != null, onClick = { menu = StreamOption.BITRATE }) {
CaptureValueIcon(CaptureIconShape.GAUGE, shortBitrate,
detail = if (config != null && config.videoBitrate == null) stringResource(R.string.stream_bitrate_auto_mark) else null)
}
DropdownMenu(expanded = menu == StreamOption.BITRATE && enabled, onDismissRequest = { menu = null }) {
StreamOptionItem(stringResource(R.string.stream_bitrate_auto), config?.videoBitrate == null, onClick = {
CaptureOptionItem(stringResource(R.string.stream_bitrate_auto), config?.videoBitrate == null, onClick = {
menu = null; config?.let { onChange(it.copy(videoBitrate = null)) }
})
mode?.let { m ->
listOf(256000, 512000, 1000000, 2000000, 4000000, 6000000, 8000000, 12000000, 16000000, 24000000, 32000000, 48000000, 64000000)
.map { it.coerceIn(m.minimumBitrate, m.maximumBitrate) }.distinct().forEach { value ->
StreamOptionItem(stringResource(R.string.stream_bitrate_value, value / 1_000_000.0),
CaptureOptionItem(stringResource(R.string.stream_bitrate_value, value / 1_000_000.0),
config?.videoBitrate == value, onClick = {
menu = null; config?.let { onChange(it.copy(videoBitrate = value)) }
})
@@ -319,13 +311,13 @@ private fun StreamCaptureToolbar(cameras: List<StreamCamera>, config: StreamCapt
}
}
Box {
StreamControlButton(stringResource(R.string.stream_resolution, mode?.label ?: "—"), enabled && modes.isNotEmpty(),
CaptureControlButton(stringResource(R.string.stream_resolution, mode?.label ?: "—"), enabled && modes.isNotEmpty(),
onClick = { menu = StreamOption.RESOLUTION }) {
StreamValueIcon(StreamIconShape.SCREEN, mode?.let { "${minOf(it.width, it.height)}p" } ?: "—",
CaptureValueIcon(CaptureIconShape.SCREEN, mode?.let { "${minOf(it.width, it.height)}p" } ?: "—",
detail = mode?.let { "${it.fps}fps" })
}
DropdownMenu(expanded = menu == StreamOption.RESOLUTION && enabled, onDismissRequest = { menu = null }) {
modes.forEach { option -> StreamOptionItem(option.label, option == mode, onClick = {
modes.forEach { option -> CaptureOptionItem(option.label, option == mode, onClick = {
menu = null; config?.let { onChange(it.withMode(option)) }
}) }
}
@@ -334,12 +326,12 @@ private fun StreamCaptureToolbar(cameras: List<StreamCamera>, config: StreamCapt
val degrees = config?.orientation?.degrees ?: 0
val front = camera?.lens?.type == LensType.SELFIE
val top = if (front) (display - degrees + 360) % 360 else (degrees - display + 360) % 360
StreamControlButton(stringResource(R.string.stream_orientation, degrees), enabled && config != null,
CaptureControlButton(stringResource(R.string.stream_orientation, degrees), enabled && config != null,
onClick = { menu = StreamOption.ORIENTATION }) { StreamRotationMark(top, degrees) }
DropdownMenu(expanded = menu == StreamOption.ORIENTATION && enabled, onDismissRequest = { menu = null }) {
for (option in listOf(StreamOrientation.UPRIGHT, StreamOrientation.RIGHT, StreamOrientation.LEFT)) {
val optionTop = if (front) (display - option.degrees + 360) % 360 else (option.degrees - display + 360) % 360
StreamOptionItem(stringResource(R.string.stream_orientation, option.degrees), config?.orientation == option,
CaptureOptionItem(stringResource(R.string.stream_orientation, option.degrees), config?.orientation == option,
onClick = { menu = null; config?.let { onChange(it.copy(orientation = option)) } },
icon = { StreamRotationMark(optionTop, option.degrees) })
}
@@ -348,108 +340,6 @@ private fun StreamCaptureToolbar(cameras: List<StreamCamera>, config: StreamCapt
}
}
/** Единый размер касания, подсказки и семантика для обычных кнопок и переключателя звука. */
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun StreamControlButton(description: String, enabled: Boolean = true, checked: Boolean? = null,
containerColor: Color? = null, onClick: () -> Unit, icon: @Composable () -> Unit) {
TooltipBox(positionProvider = TooltipDefaults.rememberPlainTooltipPositionProvider(), tooltip = { PlainTooltip { Text(description) } },
state = rememberTooltipState()) {
val modifier = Modifier.size(48.dp).semantics { contentDescription = description }
val colors = MaterialTheme.colorScheme
val background = containerColor ?: colors.surfaceVariant.copy(alpha = 0.55f)
val disabledBackground = containerColor?.copy(alpha = 0.38f) ?: colors.surfaceVariant.copy(alpha = 0.2f)
val content = if (containerColor != null) Color.White else colors.onSurface
val shape = RoundedCornerShape(12.dp)
if (checked == null) FilledTonalIconButton(onClick = onClick, enabled = enabled, modifier = modifier, shape = shape,
colors = IconButtonDefaults.filledTonalIconButtonColors(containerColor = background, contentColor = content,
disabledContainerColor = disabledBackground, disabledContentColor = content.copy(alpha = 0.38f))) {
Box(Modifier.clearAndSetSemantics {}) { icon() }
} else FilledTonalIconToggleButton(checked = checked, onCheckedChange = { onClick() }, enabled = enabled,
modifier = modifier, shape = shape,
colors = IconButtonDefaults.filledTonalIconToggleButtonColors(containerColor = background, contentColor = colors.onSurfaceVariant,
disabledContainerColor = disabledBackground, checkedContainerColor = colors.primaryContainer,
checkedContentColor = colors.onPrimaryContainer)) {
Box(Modifier.clearAndSetSemantics {}) { icon() }
}
}
}
/** Различимые силуэты камеры, микросхемы, шкалы скорости и экрана содержат текущие значения. */
@Composable
private fun StreamValueIcon(shape: StreamIconShape, value: String, detail: String? = null, front: Boolean = false) {
val frame = LocalContentColor.current
val accent = MaterialTheme.colorScheme.primary.copy(alpha = frame.alpha)
Box(Modifier.size(40.dp, 36.dp), contentAlignment = Alignment.Center) {
Canvas(Modifier.matchParentSize()) {
val w = size.width; val h = size.height
val stroke = Stroke(1.5.dp.toPx(), cap = StrokeCap.Round)
when (shape) {
StreamIconShape.CAMERA -> {
val body = Path().apply {
moveTo(w * 0.12f, h * 0.24f); lineTo(w * 0.28f, h * 0.24f)
lineTo(w * 0.36f, h * 0.08f); lineTo(w * 0.64f, h * 0.08f); lineTo(w * 0.72f, h * 0.24f)
lineTo(w * 0.88f, h * 0.24f); quadraticBezierTo(w * 0.96f, h * 0.24f, w * 0.96f, h * 0.34f)
lineTo(w * 0.96f, h * 0.84f); quadraticBezierTo(w * 0.96f, h * 0.94f, w * 0.88f, h * 0.94f)
lineTo(w * 0.12f, h * 0.94f); quadraticBezierTo(w * 0.04f, h * 0.94f, w * 0.04f, h * 0.84f)
lineTo(w * 0.04f, h * 0.34f); quadraticBezierTo(w * 0.04f, h * 0.24f, w * 0.12f, h * 0.24f); close()
}
drawPath(body, frame, style = stroke)
drawLine(accent, Offset(w * 0.43f, h * 0.08f), Offset(w * 0.57f, h * 0.08f), stroke.width, StrokeCap.Round)
}
StreamIconShape.CHIP -> {
drawRoundRect(frame, Offset(w * 0.14f, h * 0.14f), Size(w * 0.72f, h * 0.72f),
CornerRadius(3.dp.toPx()), style = stroke)
for (position in listOf(0.28f, 0.5f, 0.72f)) {
drawLine(accent, Offset(w * position, h * 0.02f), Offset(w * position, h * 0.14f), stroke.width)
drawLine(accent, Offset(w * position, h * 0.86f), Offset(w * position, h * 0.98f), stroke.width)
drawLine(accent, Offset(w * 0.02f, h * position), Offset(w * 0.14f, h * position), stroke.width)
drawLine(accent, Offset(w * 0.86f, h * position), Offset(w * 0.98f, h * position), stroke.width)
}
}
StreamIconShape.GAUGE -> {
drawArc(accent, 180f, 180f, false, Offset(w * 0.05f, h * 0.06f), Size(w * 0.9f, h * 0.9f), style = stroke)
drawLine(frame, Offset(w * 0.05f, h * 0.51f), Offset(w * 0.15f, h * 0.51f), stroke.width)
drawLine(frame, Offset(w * 0.5f, h * 0.06f), Offset(w * 0.5f, h * 0.16f), stroke.width)
drawLine(frame, Offset(w * 0.85f, h * 0.51f), Offset(w * 0.95f, h * 0.51f), stroke.width)
drawLine(frame, Offset(w * 0.16f, h * 0.94f), Offset(w * 0.84f, h * 0.94f), stroke.width, StrokeCap.Round)
}
StreamIconShape.SCREEN -> {
drawRoundRect(frame, Offset(w * 0.03f, h * 0.05f), Size(w * 0.94f, h * 0.76f),
CornerRadius(3.dp.toPx()), style = stroke)
drawLine(accent, Offset(w * 0.5f, h * 0.81f), Offset(w * 0.5f, h * 0.96f), stroke.width)
drawLine(accent, Offset(w * 0.3f, h * 0.96f), Offset(w * 0.7f, h * 0.96f), stroke.width, StrokeCap.Round)
}
}
}
when (shape) {
StreamIconShape.CAMERA -> Box(Modifier.offset(y = 4.dp)) {
if (front) Icon(Icons.Outlined.Person, contentDescription = null, modifier = Modifier.size(24.dp))
else Text(value, fontSize = 11.sp, lineHeight = 13.sp, fontWeight = FontWeight.Medium, maxLines = 1, softWrap = false)
}
StreamIconShape.CHIP -> Text(value, fontSize = 11.sp, lineHeight = 13.sp,
fontWeight = FontWeight.SemiBold, maxLines = 1, softWrap = false)
StreamIconShape.GAUGE -> {
if (detail != null) Text(detail, Modifier.align(Alignment.TopCenter).padding(top = 7.dp),
fontSize = 8.sp, lineHeight = 9.sp, fontWeight = FontWeight.Medium, color = accent, maxLines = 1)
Text(value, Modifier.offset(y = 7.dp), fontSize = 12.sp, lineHeight = 14.sp,
fontWeight = FontWeight.SemiBold, maxLines = 1, softWrap = false)
}
StreamIconShape.SCREEN -> Column(Modifier.padding(bottom = 5.dp), horizontalAlignment = Alignment.CenterHorizontally) {
Text(value, fontSize = 11.sp, lineHeight = 12.sp, fontWeight = FontWeight.SemiBold, maxLines = 1, softWrap = false)
if (detail != null) Text(detail, fontSize = 8.sp, lineHeight = 9.sp, maxLines = 1, softWrap = false)
}
}
}
}
/** Текущее значение отмечено галочкой и доступно средствам чтения экрана. */
@Composable
private fun StreamOptionItem(label: String, active: Boolean, onClick: () -> Unit, icon: @Composable (() -> Unit)? = null) {
DropdownMenuItem(text = { Text(label) }, onClick = onClick, leadingIcon = icon,
trailingIcon = { if (active) Icon(Icons.Filled.Check, contentDescription = null) }, modifier = Modifier.semantics { selected = active })
}
/** Активное ребро выделено сплошной полосой и треугольником; угол сдвинут от маркера внутрь кадра. */
@Composable
private fun StreamRotationMark(top: Int, degrees: Int) {
@@ -30,6 +30,9 @@
<string name="call_media_stop_failed">Не удалось освободить аудиоустройства для звонка.</string>
<string name="call_media_busy">Во время звонка звук рации недоступен.</string>
<string name="call_video_settings">Настройки видео</string>
<string name="call_video_unavailable">Не удалось включить передачу видео</string>
<string name="call_video_initializing">Проверка возможностей видео…</string>
<string name="call_video_mode_restored">Выбранный режим видео недоступен. Восстановлены предыдущие настройки.</string>
<string name="call_video_enable">Включить видео</string>
<string name="call_video_disable">Выключить видео</string>
<string name="call_controls">Управление звонком</string>
@@ -30,6 +30,9 @@
<string name="call_media_stop_failed">Could not release the audio devices for the call.</string>
<string name="call_media_busy">Radio audio is unavailable during a call.</string>
<string name="call_video_settings">Video settings</string>
<string name="call_video_unavailable">Unable to send video</string>
<string name="call_video_initializing">Checking video capabilities…</string>
<string name="call_video_mode_restored">Selected video mode is unavailable. Previous settings restored.</string>
<string name="call_video_enable">Turn video on</string>
<string name="call_video_disable">Turn video off</string>
<string name="call_controls">Call controls</string>
@@ -0,0 +1,42 @@
package com.utun.chat.data
import org.junit.Assert.*
import org.junit.Test
class CallVideoOrientationTest {
@Test fun sensorAnglesUseOppositeDirectionToDisplayAngles() {
for (sensor in listOf(0, 90, 180, 270)) for (angle in listOf(0, 90, 180, 270)) {
val orientation = CallVideoOrientation()
assertTrue(orientation.observe(angle, 0))
assertEquals((sensor + angle) % 360, orientation.rotation(sensor, false))
assertEquals((sensor - angle + 360) % 360, orientation.rotation(sensor, true))
}
}
@Test fun boundaryJitterDoesNotTurnFrames() {
val orientation = CallVideoOrientation()
orientation.observe(0, 0)
for (angle in listOf(44, 46, 55, 46, 44)) { orientation.observe(angle, 100); assertFalse(orientation.confirm(1000)) }
orientation.observe(65, 1000)
assertFalse(orientation.confirm(1249))
assertTrue(orientation.confirm(1250))
assertEquals(90, orientation.degrees)
orientation.observe(40, 1300)
assertFalse(orientation.confirm(2000))
}
@Test fun flatPositionAndCancelledTurnKeepLastDirection() {
val orientation = CallVideoOrientation()
orientation.observe(270, 0)
orientation.observe(350, 100)
orientation.observe(-1, 200)
assertFalse(orientation.confirm(500))
assertEquals(270, orientation.degrees)
orientation.observe(350, 600)
assertTrue(orientation.confirm(850))
assertEquals(0, orientation.degrees)
orientation.observe(180, 900)
orientation.resetPending()
assertFalse(orientation.confirm(1200))
}
}
@@ -7,6 +7,7 @@
#include "../../../src/call/call_video_bitstream.h"
#include "../../../lib/debug_config.h"
#include "../../../lib/mem.h"
#include "../../../lib/video_plane.h"
/* LongArray format: generation,width,height,fps,rotation,codec,level,maximum_bps,profile.
* TX добавляет frame_id,pts_us,flags; RX также received_us (локальный MONOTONIC).
@@ -35,6 +36,18 @@ JNIEXPORT jboolean JNICALL Java_com_utun_chat_data_CallVideoNative_open(JNIEnv *
(void)o; struct call_video_caps caps;
return !read_caps(e, bytes, &caps) && !call_video_port_open(id, &caps);
}
JNIEXPORT jbyteArray JNICALL Java_com_utun_chat_data_CallVideoNative_rotatableCaps(JNIEnv *e, jobject o, jbyteArray bytes) {
(void)o; struct call_video_caps caps; uint8_t data[CALL_VIDEO_CAPS_BYTES];
if (read_caps(e, bytes, &caps)) return NULL;
for (unsigned i = 0; i < 2; i++) {
if (caps.encode[i].width && call_video_limit_orientations(&caps.encode[i], i + 1)) return NULL;
if (caps.decode[i].width && call_video_limit_orientations(&caps.decode[i], i + 1)) return NULL;
}
if (call_video_caps_encode(&caps, data)) return NULL;
jbyteArray out = (*e)->NewByteArray(e, sizeof(data));
if (out) (*e)->SetByteArrayRegion(e, out, 0, sizeof(data), (jbyte *)data);
return out;
}
JNIEXPORT void JNICALL Java_com_utun_chat_data_CallVideoNative_close(JNIEnv *e, jobject o, jlong id) {
(void)e; (void)o; call_video_port_close(id);
}
@@ -49,8 +62,25 @@ JNIEXPORT jlongArray JNICALL Java_com_utun_chat_data_CallVideoNative_status(JNIE
(void)o; struct call_video_port_status s;
if (call_video_port_status(id, &s)) return NULL;
jlong values[] = {s.key_serial, s.rate_generation, s.rate_request, s.rate_bps, s.configured_generation,
s.error, s.minimum_bps, s.maximum_bps, s.decoded_generation, s.decoded_accepted, s.can_encode};
jlongArray out = (*e)->NewLongArray(e, 11); if (out) (*e)->SetLongArrayRegion(e, out, 0, 11, values); return out;
s.error, s.minimum_bps, s.maximum_bps, s.decoded_generation, s.decoded_accepted, s.can_encode, s.tx_idle};
jlongArray out = (*e)->NewLongArray(e, 12); if (out) (*e)->SetLongArrayRegion(e, out, 0, 12, values); return out;
}
/* ByteBuffer.slice() задаёт первый пиксель и доступный limit; JNI не удерживает адреса. */
JNIEXPORT jboolean JNICALL Java_com_utun_chat_data_CallVideoNative_rotatePlane(JNIEnv *e, jobject o,
jobject source, jint source_row, jint source_pixel, jobject destination, jint destination_row, jint destination_pixel,
jint width, jint height, jint rotation) {
(void)o;
jlong source_size = source ? (*e)->GetDirectBufferCapacity(e, source) : -1;
jlong destination_size = destination ? (*e)->GetDirectBufferCapacity(e, destination) : -1;
if (source_size < 0 || destination_size < 0 || source_row <= 0 || source_pixel <= 0
|| destination_row <= 0 || destination_pixel <= 0 || width <= 0 || height <= 0 || rotation < 0) {
DEBUG_ERROR(DEBUG_CATEGORY_CALL, "video rotate JNI invalid planes size=%lld/%lld geometry=%dx%d rotation=%d",
(long long)source_size, (long long)destination_size, width, height, rotation);
return JNI_FALSE;
}
struct video_plane src = {(*e)->GetDirectBufferAddress(e, source), source_size, source_row, source_pixel};
struct video_plane dst = {(*e)->GetDirectBufferAddress(e, destination), destination_size, destination_row, destination_pixel};
return !video_plane_rotate(&src, &dst, width, height, rotation);
}
JNIEXPORT jlongArray JNICALL Java_com_utun_chat_data_CallVideoNative_choose(
JNIEnv *e, jobject o, jbyteArray local, jbyteArray remote, jlongArray desired) {
@@ -151,8 +181,9 @@ JNIEXPORT jlongArray JNICALL Java_com_utun_chat_data_CallVideoNative_inspect(JNI
jlong values[] = {s.width, s.height, s.level, s.profile, s.dpb};
jlongArray out = (*e)->NewLongArray(e, 5); if (out) (*e)->SetLongArrayRegion(e, out, 0, 5, values); return out;
}
JNIEXPORT jbyteArray JNICALL Java_com_utun_chat_data_CallVideoNative_probeKey(JNIEnv *e, jobject o, jint codec, jint profile) {
(void)o; uint8_t bytes[8192]; int size = call_video_probe_key(codec, profile, bytes, sizeof(bytes));
JNIEXPORT jbyteArray JNICALL Java_com_utun_chat_data_CallVideoNative_probeKey(
JNIEnv *e, jobject o, jint codec, jint profile, jboolean portrait) {
(void)o; uint8_t bytes[8192]; int size = call_video_probe_key(codec, profile, portrait, bytes, sizeof(bytes));
if (size < 0) return NULL;
jbyteArray out = (*e)->NewByteArray(e, size); if (out) (*e)->SetByteArrayRegion(e, out, 0, size, (jbyte *)bytes); return out;
}
+38 -29
View File
@@ -227,37 +227,44 @@ call_video_caps CallVideoDecoder::capabilities() {
for (unsigned codec = 1; codec <= 2; ++codec) {
unsigned profiles = 0, hardwareProfiles = 0, pipeline = 0;
for (unsigned profile = 1; profile <= (codec == 1 ? 2u : 1u); ++profile) {
uint8_t bytes[8192]; int size = call_video_probe_key(codec, profile, bytes, sizeof(bytes));
if (size < 0) continue;
for (Backend backend : {Backend::HardwareOnly, Backend::SoftwareOnly}) {
CallVideoDecoder decoder(backend);
call_video_frame input{}; input.call_id = 1; input.flags = CALL_VIDEO_KEY;
input.data = bytes; input.size = input.length = size;
input.format = {1, 1280, 720, 30, 0, (uint8_t)codec, (uint8_t)(codec == 1 ? 31 : 93), 4000000, (uint8_t)profile};
unsigned sent = 0, received = 0; bool valid = true, outputBeforeEos = false;
const int64_t start = call_video_playout_now_us();
while (valid && received < 8 && call_video_playout_now_us() - start < 1500000) {
if (sent < 8 && call_video_playout_now_us() - start >= sent * 33333ll) {
input.frame_id = sent + 1; input.pts_us = 1000000 + sent * 33333ll;
int result = decoder.submit(input); if (result < 0) valid = false; else if (result) ++sent;
bool bothOrientations = true;
unsigned backendPipeline = 0;
for (int portrait = 0; portrait <= 1 && bothOrientations; ++portrait) {
uint8_t bytes[8192]; int size = call_video_probe_key(codec, profile, portrait, bytes, sizeof(bytes));
if (size < 0) { bothOrientations = false; break; }
CallVideoDecoder decoder(backend);
call_video_frame input{}; input.call_id = 1; input.flags = CALL_VIDEO_KEY;
input.data = bytes; input.size = input.length = size;
input.format = {1, (uint16_t)(portrait ? 720 : 1280), (uint16_t)(portrait ? 1280 : 720), 30, 0,
(uint8_t)codec, (uint8_t)(codec == 1 ? 31 : 93), 4000000, (uint8_t)profile};
unsigned sent = 0, received = 0; bool valid = true, outputBeforeEos = false;
const int64_t start = call_video_playout_now_us();
while (valid && received < 8 && call_video_playout_now_us() - start < 1500000) {
if (sent < 8 && call_video_playout_now_us() - start >= sent * 33333ll) {
input.frame_id = sent + 1; input.pts_us = 1000000 + sent * 33333ll;
int result = decoder.submit(input); if (result < 0) valid = false; else if (result) ++sent;
}
call_video_frame output{}; QImage image;
int result = decoder.receive(output, image);
if (result < 0) valid = false;
else if (result) {
valid = output.pts_us == 1000000 + received * 33333ll;
if (!decoder.m->eos) outputBeforeEos = true;
++received;
}
if (sent == 8 && decoder.finish() < 0) valid = false;
if (valid && received < 8) QThread::usleep(1000);
}
call_video_frame output{}; QImage image;
int result = decoder.receive(output, image);
if (result < 0) valid = false;
else if (result) {
valid = output.pts_us == 1000000 + received * 33333ll;
if (!decoder.m->eos) outputBeforeEos = true;
++received;
}
if (sent == 8 && decoder.finish() < 0) valid = false;
if (valid && received < 8) QThread::usleep(1000);
valid = valid && received == 8 && outputBeforeEos;
DEBUG_INFO(DEBUG_CATEGORY_CALL, "video decoder probe codec=%u profile=%u portrait=%d backend=%d valid=%d inputs=%u outputs=%u pipeline=%u",
codec, profile, portrait, int(backend), valid, sent, received, decoder.pipelineFrames());
bothOrientations = valid; backendPipeline = std::max(backendPipeline, decoder.pipelineFrames());
}
valid = valid && received == 8 && outputBeforeEos;
DEBUG_INFO(DEBUG_CATEGORY_CALL, "video decoder probe codec=%u profile=%u backend=%d valid=%d inputs=%u outputs=%u pipeline=%u",
codec, profile, int(backend), valid, sent, received, decoder.pipelineFrames());
if (valid) {
if (bothOrientations) {
profiles |= profile;
if (backend == Backend::HardwareOnly) { hardwareProfiles |= profile; pipeline = std::max(pipeline, decoder.pipelineFrames()); }
if (backend == Backend::HardwareOnly) { hardwareProfiles |= profile; pipeline = std::max(pipeline, backendPipeline); }
break;
}
}
@@ -265,8 +272,10 @@ call_video_caps CallVideoDecoder::capabilities() {
#ifdef HAVE_FFMPEG
gpuPipeline[codec - 1].store(pipeline); gpuProfiles[codec - 1].store(hardwareProfiles);
#endif
if (profiles) caps.decode[codec - 1] = {1280, 720, 30, 1280 * 720 * 30, 4000000,
(uint8_t)profiles, (uint8_t)(codec == 1 ? 31 : 93)};
if (profiles) {
call_video_limit limit{1280, 720, 30, 1280 * 720 * 30, 4000000, (uint8_t)profiles, (uint8_t)(codec == 1 ? 31 : 93)};
if (!call_video_limit_orientations(&limit, codec)) caps.decode[codec - 1] = limit;
}
}
});
return caps;
+11 -4
View File
@@ -212,11 +212,18 @@ void CallVideoEngine::transmit() {
for (unsigned codec = 1; codec <= 2 && m->running; ++codec) {
call_video_format probe{1, 1280, 720, 30, 0, (uint8_t)codec, (uint8_t)(codec == 1 ? 31 : 93), 4000000,
(uint8_t)(codec == 1 ? CALL_VIDEO_CONSTRAINED_BASELINE : CALL_VIDEO_MAIN)};
CallVideoEncoder encoder;
if (encoder.open(probe, 1000000)) {
bool bothOrientations = true;
for (int portrait = 0; portrait <= 1 && bothOrientations && m->running; ++portrait) {
probe.width = portrait ? 720 : 1280; probe.height = portrait ? 1280 : 720;
CallVideoEncoder encoder;
bothOrientations = encoder.open(probe, 1000000);
if (!bothOrientations) DEBUG_WARN(DEBUG_CATEGORY_CALL, "call codec probe unavailable codec=%u size=%ux%u: %s",
codec, probe.width, probe.height, qPrintable(encoder.error()));
}
if (bothOrientations && m->running) {
call_video_limit limit{1280, 720, 30, 1280 * 720 * 30, 4000000, probe.profile, probe.level};
caps.encode[codec - 1] = limit;
} else DEBUG_WARN(DEBUG_CATEGORY_CALL, "call codec probe unavailable codec=%u: %s", codec, qPrintable(encoder.error()));
if (!call_video_limit_orientations(&limit, codec)) caps.encode[codec - 1] = limit;
}
}
if (!m->running) return;
if (call_video_port_open(m->id, &caps) < 0) { m->report(tr("Video transport is unavailable")); return; }
@@ -129,6 +129,10 @@ private slots:
void independentDecoders() {
auto caps = CallVideoDecoder::capabilities();
QCOMPARE(caps.decode[0].profiles, uint8_t(3)); QCOMPARE(caps.decode[1].profiles, uint8_t(1));
for (const auto& limit : caps.decode) {
QCOMPARE(limit.width, uint16_t(1280)); QCOMPARE(limit.height, uint16_t(1280));
QCOMPARE(limit.pixels_per_second, uint32_t(1280 * 720 * 30));
}
QCOMPARE(caps.encode[0].width, uint16_t(0)); QCOMPARE(caps.encode[1].width, uint16_t(0));
const QString directory = qEnvironmentVariable("UTUN_CALL_VIDEO_REMOTE_DIR");
if (!directory.isEmpty()) {
@@ -150,6 +154,47 @@ private slots:
}
}
}
void bothOrientations_data() {
QTest::addColumn<int>("codec"); QTest::newRow("H264") << 1; QTest::newRow("H265") << 2;
}
void bothOrientations() {
QFETCH(int, codec);
const char* backend = codec == 1 ? "libx264" : "libx265";
if (!avcodec_find_encoder_by_name(backend)) QSKIP("Software encoder unavailable");
auto config = configuration(codec);
auto receiver = CallVideoDecoder::capabilities();
call_video_caps sender{};
call_video_limit limit{1280, 720, 30, 1280 * 720 * 30, 4000000, config.profile, config.level};
// Обе пробы используют production open с проверкой настоящего KEY.
for (bool portrait : {false, true}) {
config.width = portrait ? 720 : 1280; config.height = portrait ? 1280 : 720;
CallVideoEncoder probe; QVERIFY2(probe.open(config, 1000000, backend), qPrintable(probe.error()));
}
QVERIFY(!call_video_limit_orientations(&limit, codec)); sender.encode[codec - 1] = limit;
CallVideoDecoder decoder(CallVideoDecoder::Backend::SoftwareOnly);
uint64_t pts = 1000000, frameId = 0;
for (bool portrait : {false, true, false}) {
config.width = portrait ? 720 : 1280; config.height = portrait ? 1280 : 720;
config.generation++; config.level = 0;
QCOMPARE(call_video_choose(&sender, &receiver, &config), codec);
QVERIFY(call_video_format_supported(&sender, &receiver, &config));
CallVideoEncoder encoder; QVERIFY2(encoder.open(config, 1000000, backend), qPrintable(encoder.error()));
QImage image(config.width, config.height, QImage::Format_RGB32); image.fill(Qt::blue);
for (int i = 0; i < 4; ++i) {
QList<LiveEncodedFrame> outputs; CallVideoRateCompletion completion;
QVERIFY2(encoder.encode(image, pts, i == 0, outputs, completion), qPrintable(encoder.error()));
QCOMPARE(outputs.size(), 1); QCOMPARE(outputs.front().pts, pts);
call_video_frame input{}; input.call_id = 1; input.frame_id = ++frameId; input.pts_us = pts;
input.format = config; input.flags = outputs.front().key ? CALL_VIDEO_KEY : 0;
input.data = reinterpret_cast<const uint8_t*>(outputs.front().data.constData());
input.size = input.length = outputs.front().data.size();
call_video_frame output{}; QImage decoded;
QCOMPARE(decoder.submit(input), 1); QCOMPARE(decoder.receive(output, decoded), 1);
QCOMPARE(output.pts_us, int64_t(pts)); QCOMPARE(decoded.size(), image.size());
QCOMPARE(output.format.generation, config.generation); pts += 33333;
}
}
}
void switchesAtScheduledIdr() {
QFETCH(int, codec);
auto config = configuration(codec);
+35
View File
@@ -0,0 +1,35 @@
#!/usr/bin/env python3
"""Генерирует портретные KEY-пробы звонка через FFmpeg libx264/libx265."""
import pathlib
import re
import subprocess
root = pathlib.Path(__file__).resolve().parent.parent
arrays = []
for codec in (1, 2):
command = ["ffmpeg", "-hide_banner", "-loglevel", "error", "-f", "lavfi", "-i", "color=gray:size=720x1280:rate=30",
"-frames:v", "1", "-c:v", "libx264" if codec == 1 else "libx265", "-preset", "ultrafast", "-tune", "zerolatency"]
if codec == 1:
command += ["-profile:v", "baseline", "-level:v", "3.1", "-x264-params", "ref=3:slices=1:threads=1"]
else:
command += ["-profile:v", "main", "-x265-params", "level-idc=3.1:ref=3:bframes=0:pools=none:frame-threads=1:log-level=error"]
stream = subprocess.check_output(command + ["-f", "h264" if codec == 1 else "hevc", "pipe:1"])
config, picture = bytearray(), bytearray()
for nal in re.split(b"\x00\x00\x00?\x01", stream):
if not nal:
continue
kind = nal[0] & 31 if codec == 1 else (nal[0] >> 1) & 63
if kind in ((7, 8) if codec == 1 else (32, 33, 34)):
config += b"\x00\x00\x00\x01" + nal
elif kind in ((5,) if codec == 1 else (19, 20)):
picture += b"\x00\x00\x00\x01" + nal
assert config and picture
packet = len(config).to_bytes(4, "big") + config + picture
assert len(packet) <= 8192
rows = [" " + ", ".join(f"0x{byte:02x}" for byte in packet[i:i + 20]) + "," for i in range(0, len(packet), 20)]
arrays.append(f"static const uint8_t call_video_portrait_{codec}[] = {{\n" + "\n".join(rows) + "\n};\n")
target = root / "src/call/call_video_portrait_fixtures.h"
target.write_text("/* 720x1280@30, gray YUV420, ref=3, no B: tools/generate_call_video_portrait.py.\n"
" * Configuration + IDR; SEI исключены. */\n#ifndef CALL_VIDEO_PORTRAIT_FIXTURES_H\n"
"#define CALL_VIDEO_PORTRAIT_FIXTURES_H\n#include <stdint.h>\n" + "".join(arrays) + "#endif\n")
print(target)