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