diff --git a/doc/call_video.txt b/doc/call_video.txt index 48d2352c..056d409a 100644 --- a/doc/call_video.txt +++ b/doc/call_video.txt @@ -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-проверки, живой межплатформенный звонок новой пары сборок ещё не проверен. diff --git a/lib/Makefile.am b/lib/Makefile.am index f8cda347..224ce970 100644 --- a/lib/Makefile.am +++ b/lib/Makefile.am @@ -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 \ diff --git a/lib/video_plane.c b/lib/video_plane.c new file mode 100644 index 00000000..5817aea5 --- /dev/null +++ b/lib/video_plane.c @@ -0,0 +1,40 @@ +#include "video_plane.h" +#include "debug_config.h" +#include + +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; +} diff --git a/lib/video_plane.h b/lib/video_plane.h new file mode 100644 index 00000000..d9931847 --- /dev/null +++ b/lib/video_plane.h @@ -0,0 +1,26 @@ +/* Копирование одной плоскости YUV с поворотом по часовой стрелке, без выделений. + * Вызывается отдельно для Y/U/V; размеры chroma уже уменьшены вызывающим. + * Буферы источника и назначения не должны пересекаться. */ +#ifndef VIDEO_PLANE_H +#define VIDEO_PLANE_H +#include +#include +#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 diff --git a/src/Makefile.am b/src/Makefile.am index a4f8d85d..689009ae 100644 --- a/src/Makefile.am +++ b/src/Makefile.am @@ -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 \ diff --git a/src/call/call_video.c b/src/call/call_video.c index d9808c06..cf0a3ee9 100644 --- a/src/call/call_video.c +++ b/src/call/call_video.c @@ -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; diff --git a/src/call/call_video.h b/src/call/call_video.h index 742ce168..7099c12e 100644 --- a/src/call/call_video.h +++ b/src/call/call_video.h @@ -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 не меняется. */ diff --git a/src/call/call_video_bitstream.c b/src/call/call_video_bitstream.c index e9f74fb3..cd6f086d 100644 --- a/src/call/call_video_bitstream.c +++ b/src/call/call_video_bitstream.c @@ -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 @@ -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; diff --git a/src/call/call_video_bitstream.h b/src/call/call_video_bitstream.h index 6da88fe5..feb1d071 100644 --- a/src/call/call_video_bitstream.h +++ b/src/call/call_video_bitstream.h @@ -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 diff --git a/src/call/call_video_port.c b/src/call/call_video_port.c index 17177097..cd598b50 100644 --- a/src/call/call_video_port.c +++ b/src/call/call_video_port.c @@ -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; diff --git a/src/call/call_video_port.h b/src/call/call_video_port.h index f9da9189..b5f76a46 100644 --- a/src/call/call_video_port.h +++ b/src/call/call_video_port.h @@ -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. diff --git a/src/call/call_video_portrait_fixtures.h b/src/call/call_video_portrait_fixtures.h new file mode 100644 index 00000000..d1019612 --- /dev/null +++ b/src/call/call_video_portrait_fixtures.h @@ -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 +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 diff --git a/src/call/call_video_transport.c b/src/call/call_video_transport.c index cececfaf..b4f78918 100644 --- a/src/call/call_video_transport.c +++ b/src/call/call_video_transport.c @@ -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) { diff --git a/tests/Makefile.am b/tests/Makefile.am index 403b80a9..a9e0b6dc 100644 --- a/tests/Makefile.am +++ b/tests/Makefile.am @@ -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 diff --git a/tests/test_call_video.c b/tests/test_call_video.c index 8d00f625..f7d734e2 100644 --- a/tests/test_call_video.c +++ b/tests/test_call_video.c @@ -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; } diff --git a/tests/test_call_video_port.c b/tests/test_call_video_port.c index 4fef3a29..be9eb86d 100644 --- a/tests/test_call_video_port.c +++ b/tests/test_call_video_port.c @@ -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); diff --git a/tests/test_video_plane.c b/tests/test_video_plane.c new file mode 100644 index 00000000..eefcba43 --- /dev/null +++ b/tests/test_video_plane.c @@ -0,0 +1,41 @@ +#include "../lib/video_plane.h" +#include +#include +#include + +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; +} diff --git a/tools/chatgui-android/app/src/debug/java/com/utun/chat/data/CallVideoProbe.kt b/tools/chatgui-android/app/src/debug/java/com/utun/chat/data/CallVideoProbe.kt index 65f7f570..4d2f7be9 100644 --- a/tools/chatgui-android/app/src/debug/java/com/utun/chat/data/CallVideoProbe.kt +++ b/tools/chatgui-android/app/src/debug/java/com/utun/chat/data/CallVideoProbe.kt @@ -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) diff --git a/tools/chatgui-android/app/src/debug/java/com/utun/chat/data/CallVideoRotationProbe.kt b/tools/chatgui-android/app/src/debug/java/com/utun/chat/data/CallVideoRotationProbe.kt new file mode 100644 index 00000000..84953402 --- /dev/null +++ b/tools/chatgui-android/app/src/debug/java/com/utun/chat/data/CallVideoRotationProbe.kt @@ -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, decoders: Map, 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() + 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, 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() } + } +} diff --git a/tools/chatgui-android/app/src/main/cpp/CMakeLists.txt b/tools/chatgui-android/app/src/main/cpp/CMakeLists.txt index 3d7f3b8c..29067ae0 100644 --- a/tools/chatgui-android/app/src/main/cpp/CMakeLists.txt +++ b/tools/chatgui-android/app/src/main/cpp/CMakeLists.txt @@ -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 ) diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallVideoCodec.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallVideoCodec.kt index b33fdabf..03d641e7 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallVideoCodec.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallVideoCodec.kt @@ -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) 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(); 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) diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallVideoEngine.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallVideoEngine.kt index dbf9459d..8de8e58f 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallVideoEngine.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallVideoEngine.kt @@ -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) +data class CallVideoCamera(val id: String, val name: String, val sizes: List, 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 = emptyList(), val codecs: List = 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> = emptyMap(), val notice: Int? = null) { + fun sizes(camera: String = settings.camera, codec: Int = settings.codec): List { + 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() 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() diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallVideoNative.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallVideoNative.kt index 6d9d1cbe..804a54d5 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallVideoNative.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallVideoNative.kt @@ -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) } diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallVideoOrientation.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallVideoOrientation.kt new file mode 100644 index 00000000..bff775a5 --- /dev/null +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallVideoOrientation.kt @@ -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 +} diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/CallScreen.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/CallScreen.kt index e47fe744..bb43437d 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/CallScreen.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/CallScreen.kt @@ -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.REMOTE) } + var selectedVideo by rememberSaveable(st?.callId) { mutableStateOf(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( diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/CallVideoPanel.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/CallVideoPanel.kt index 092622c5..8be93645 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/CallVideoPanel.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/CallVideoPanel.kt @@ -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(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, 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) }) } - } - } -} diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/CaptureControls.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/CaptureControls.kt new file mode 100644 index 00000000..d9b77f52 --- /dev/null +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/CaptureControls.kt @@ -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 }) +} diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/StreamScreen.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/StreamScreen.kt index bf8e1a1f..b2c87d09 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/StreamScreen.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/StreamScreen.kt @@ -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, 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, 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, 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, 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, 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, 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) { diff --git a/tools/chatgui-android/app/src/main/res/values-ru/strings.xml b/tools/chatgui-android/app/src/main/res/values-ru/strings.xml index 17f68530..e11100d5 100644 --- a/tools/chatgui-android/app/src/main/res/values-ru/strings.xml +++ b/tools/chatgui-android/app/src/main/res/values-ru/strings.xml @@ -30,6 +30,9 @@ Не удалось освободить аудиоустройства для звонка. Во время звонка звук рации недоступен. Настройки видео + Не удалось включить передачу видео + Проверка возможностей видео… + Выбранный режим видео недоступен. Восстановлены предыдущие настройки. Включить видео Выключить видео Управление звонком diff --git a/tools/chatgui-android/app/src/main/res/values/strings.xml b/tools/chatgui-android/app/src/main/res/values/strings.xml index 071170a8..0e3727a1 100644 --- a/tools/chatgui-android/app/src/main/res/values/strings.xml +++ b/tools/chatgui-android/app/src/main/res/values/strings.xml @@ -30,6 +30,9 @@ Could not release the audio devices for the call. Radio audio is unavailable during a call. Video settings + Unable to send video + Checking video capabilities… + Selected video mode is unavailable. Previous settings restored. Turn video on Turn video off Call controls diff --git a/tools/chatgui-android/app/src/test/java/com/utun/chat/data/CallVideoOrientationTest.kt b/tools/chatgui-android/app/src/test/java/com/utun/chat/data/CallVideoOrientationTest.kt new file mode 100644 index 00000000..1a8c97ca --- /dev/null +++ b/tools/chatgui-android/app/src/test/java/com/utun/chat/data/CallVideoOrientationTest.kt @@ -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)) + } +} diff --git a/tools/chatgui-android/jni_bridge/android_call_video_jni.c b/tools/chatgui-android/jni_bridge/android_call_video_jni.c index cea09b6c..1e6f6c25 100644 --- a/tools/chatgui-android/jni_bridge/android_call_video_jni.c +++ b/tools/chatgui-android/jni_bridge/android_call_video_jni.c @@ -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; } diff --git a/tools/chatgui/src/call_video_decoder.cpp b/tools/chatgui/src/call_video_decoder.cpp index 24b672f4..fda27853 100644 --- a/tools/chatgui/src/call_video_decoder.cpp +++ b/tools/chatgui/src/call_video_decoder.cpp @@ -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; diff --git a/tools/chatgui/src/call_video_engine.cpp b/tools/chatgui/src/call_video_engine.cpp index 45d93f88..5dff5e89 100644 --- a/tools/chatgui/src/call_video_engine.cpp +++ b/tools/chatgui/src/call_video_engine.cpp @@ -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; } diff --git a/tools/chatgui/tests/test_call_video_encoder.cpp b/tools/chatgui/tests/test_call_video_encoder.cpp index b6a62e9e..821ba34a 100644 --- a/tools/chatgui/tests/test_call_video_encoder.cpp +++ b/tools/chatgui/tests/test_call_video_encoder.cpp @@ -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("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 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(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); diff --git a/tools/generate_call_video_portrait.py b/tools/generate_call_video_portrait.py new file mode 100644 index 00000000..b3075c7d --- /dev/null +++ b/tools/generate_call_video_portrait.py @@ -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 \n" + "".join(arrays) + "#endif\n") +print(target)