From b4420f446c9a1e3b72a488e16e9036c58621641f Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Wed, 19 Aug 2026 21:03:55 +0300 Subject: [PATCH] =?UTF-8?q?fix(app-glasses):=20=D0=B1=D0=B8-=D0=BD=D0=BE?= =?UTF-8?q?=D0=BA=D1=83=D0=BB=D1=8F=D1=80=D0=BD=D1=8B=D0=B9=20=D1=80=D0=B5?= =?UTF-8?q?=D0=BD=D0=B4=D0=B5=D1=80=20RayNeo=20=E2=80=94=20letterbox=20+?= =?UTF-8?q?=20scale=3Dfalse=20(=D0=BF=D0=BE=D0=BB=D0=BD=D1=8B=D0=B9=20?= =?UTF-8?q?=D0=BA=D0=B0=D0=B4=D1=80=20=D0=B2=20=D0=BA=D0=B0=D0=B6=D0=B4?= =?UTF-8?q?=D1=8B=D0=B9=20=D0=B3=D0=BB=D0=B0=D0=B7)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Живой тест на RayNeo X2: каждый глаз = 640x480 (пол-экрана 1280x480), видео 896x480 (16:9) растягивалось на весь глаз → «квадратное» (сплющено по горизонтали). - letterboxRect(videoW,videoH,viewW,viewH): FIT-вьюпорт с полями (страховка для произвольных поверхностей) - Binocular(scale=true→false): два полных кадра 640x343 в двух половинах экрана (вместо scaleX 0.5 половинки кадра) - onVideoSizeChanged → videoWidth/Height для letterbox - Тесты 32/0 (LetterboxRectTest 5) - Пользователь: «на глаз размер поменялся, будто бы норм» --- TASK.md | 32 +++++++++++ .../glasses/ui/BinocularVideoPlayer.kt | 57 ++++++++++++++++++- .../viewmate/glasses/ui/GlassesMovieScreen.kt | 2 +- .../viewmate/glasses/LetterboxRectTest.kt | 49 ++++++++++++++++ 4 files changed, 138 insertions(+), 2 deletions(-) create mode 100644 TASK.md create mode 100644 app-glasses/src/test/kotlin/pw/binom/viewmate/glasses/LetterboxRectTest.kt diff --git a/TASK.md b/TASK.md new file mode 100644 index 0000000..bbf1b9a --- /dev/null +++ b/TASK.md @@ -0,0 +1,32 @@ +# Фикс би-нокулярного рендера RayNeo: scale=true → scale=false + +Живой тест на RayNeo X2: экран 1280×480, каждый глаз видит СВОЮ половину +(640×480). `Binocular(scale = true)` (GlassesMovieScreen.kt:63) рисует +контент на весь экран и сжимает scaleX 0.5 → каждый SurfaceView (буфер +~850×455, полный кадр) визуально 425px в половине глаза 640px → **каждый +глаз видит ПОЛОВИНУ кадра**, растянутую на 640px (масштаб ×1.5) → видео +выглядит «сплющенным по горизонтали» (жалоба пользователя). + +SurfaceFlinger подтверждает: слои #0/#1 с scaleX=0.5006, translate 215/640. + +## Изменение (app-glasses, GlassesMovieScreen.kt) + +`Binocular(scale = true)` → `Binocular(scale = false)`. + +Что это даёт (scale=false — ветка Row + two Columns weight 1f): +- BinocularScreenSize = (ширина/2, высота) = (640, 480); +- calculateVideoSizeFull(896×480, maxWidth=640, maxHeight=455) → + SurfaceView 640×343 (16:9 вписан в пол-экрана, поля 68+69 сверху/снизу + — чёрный фон Row); +- ДВА SurfaceView 640×343: левый в левой половине, правый в правой — + каждый глаз видит ПОЛНЫЙ кадр с полями. Пропорции правильные. + +letterboxRect в drawFrame оставить (страховка: SurfaceView 640×343 = +ровно 16:9 — letterboxRect вернёт полный viewport, ничего не сломает). + +## Проверка +- Тесты/сборка зелёные (`./gradlew :app-glasses:testDebugUnitTest + :app-glasses:assembleDebug`). +- Железо (после установки): скрин очков — ДВА одинаковых кадра 640×343 + (поля сверху/снизу), в каждом глазу полный кадр; пользователь видит + правильные пропорции (не «квадратное»). diff --git a/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/ui/BinocularVideoPlayer.kt b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/ui/BinocularVideoPlayer.kt index fa5b56f..463fa8d 100644 --- a/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/ui/BinocularVideoPlayer.kt +++ b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/ui/BinocularVideoPlayer.kt @@ -12,7 +12,10 @@ import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.viewinterop.AndroidView +import com.google.android.exoplayer2.Player import com.google.android.exoplayer2.SimpleExoPlayer +import com.google.android.exoplayer2.video.VideoSize +import kotlin.math.roundToInt import pw.binom.viewmate.glasses.log import pw.binom.viewmate.glasses.render.EglCore import pw.binom.viewmate.glasses.render.FullFrameRect @@ -72,6 +75,21 @@ class EglVideoRenderer(private val player: SimpleExoPlayer) { private val outputs = mutableListOf() + // Размер видео (0,0 пока неизвестен) — нужен для letterbox-вьюпорта. + private var videoWidth = 0 + private var videoHeight = 0 + + private val videoSizeListener = object : Player.Listener { + override fun onVideoSizeChanged(videoSize: VideoSize) { + videoWidth = videoSize.width + videoHeight = videoSize.height + } + } + + init { + player.addListener(videoSizeListener) + } + private class Output( val holder: SurfaceHolder, val surface: WindowSurface, @@ -144,7 +162,14 @@ class EglVideoRenderer(private val player: SimpleExoPlayer) { private fun drawFrame(blit: FullFrameRect, windowSurface: WindowSurface, viewWidth: Int, viewHeight: Int) { windowSurface.makeCurrent() - GLES20.glViewport(0, 0, viewWidth, viewHeight) + val rect = letterboxRect(videoWidth, videoHeight, viewWidth, viewHeight) + if (rect == null) { + // Размер видео ещё неизвестен — рисуем на всю поверхность. + GLES20.glViewport(0, 0, viewWidth, viewHeight) + } else { + // Letterbox: поля (чёрные, т.к. фон/очистка) сверху/снизу или сбоку. + GLES20.glViewport(rect.x0, rect.y0, rect.w, rect.h) + } blit.drawFrame(textureId, transformMatrix) windowSurface.swapBuffers() } @@ -154,6 +179,8 @@ class EglVideoRenderer(private val player: SimpleExoPlayer) { released = true log("glasses", "EglVideoRenderer: release") + player.removeListener(videoSizeListener) + // Отвязать плеер (плеер мог быть уже освобождён — не падаем). val ps = playerSurface if (ps != null) { @@ -179,3 +206,31 @@ class EglVideoRenderer(private val player: SimpleExoPlayer) { eglCore = null } } + +/** Прямоугольник вписывания видео в поверхность (letterbox): левый-нижний угол и размеры. */ +data class Rect( + val x0: Int, + val y0: Int, + val w: Int, + val h: Int, +) + +/** + * Letterbox (FIT) с сохранением пропорций: вписывает видео в поверхность. + * Поля по вертикали (видео шире поверхности) или по горизонтали (видео уже). + * Возвращает null, если размеры неизвестны/некорректны (нули) — тогда нужен полный вьюпорт. + */ +fun letterboxRect(videoW: Int, videoH: Int, viewW: Int, viewH: Int): Rect? { + if (videoW <= 0 || videoH <= 0 || viewW <= 0 || viewH <= 0) return null + val videoAspect = videoW.toFloat() / videoH + val viewAspect = viewW.toFloat() / viewH + return if (videoAspect > viewAspect) { + // Видео шире поверхности: вписываем по ширине, поля сверху/снизу. + val h = (viewW / videoAspect).roundToInt() + Rect(0, (viewH - h) / 2, viewW, h) + } else { + // Видео уже поверхности: вписываем по высоте, поля сбоку. + val w = (viewH * videoAspect).roundToInt() + Rect((viewW - w) / 2, 0, w, viewH) + } +} diff --git a/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/ui/GlassesMovieScreen.kt b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/ui/GlassesMovieScreen.kt index 3b18184..8dea27d 100644 --- a/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/ui/GlassesMovieScreen.kt +++ b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/ui/GlassesMovieScreen.kt @@ -65,7 +65,7 @@ fun GlassesMovieScreen( } } - Binocular(scale = true) { + Binocular(scale = false) { val screenWidthPx = BinocularScreenSize.current.width val screenHeightPx = BinocularScreenSize.current.height diff --git a/app-glasses/src/test/kotlin/pw/binom/viewmate/glasses/LetterboxRectTest.kt b/app-glasses/src/test/kotlin/pw/binom/viewmate/glasses/LetterboxRectTest.kt new file mode 100644 index 0000000..50fda73 --- /dev/null +++ b/app-glasses/src/test/kotlin/pw/binom/viewmate/glasses/LetterboxRectTest.kt @@ -0,0 +1,49 @@ +package pw.binom.viewmate.glasses + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import pw.binom.viewmate.glasses.ui.Rect +import pw.binom.viewmate.glasses.ui.letterboxRect + +class LetterboxRectTest { + + @Test + fun wideVideoIntoPortraitSurfaceLetterboxesTopBottom() { + // Видео 16:9 (896×480) в поверхность 4:3 (640×480): поля сверху/снизу. + val rect = letterboxRect(videoW = 896, videoH = 480, viewW = 640, viewH = 480) + val expected = Rect(x0 = 0, y0 = 68, w = 640, h = 343) + assertEquals(expected, rect) + } + + @Test + fun squareVideoIntoSideSurfaceLetterboxesSides() { + // Видео 4:3 (640×480) в поверхность 16:9 (896×480): поля сбоку. + val rect = letterboxRect(videoW = 640, videoH = 480, viewW = 896, viewH = 480) + val expected = Rect(x0 = 128, y0 = 0, w = 640, h = 480) + assertEquals(expected, rect) + } + + @Test + fun sameAspectFillsSurface() { + // Видео 16:9 (896×480) в поверхность 16:9 (896×480): полный вьюпорт. + val rect = letterboxRect(videoW = 896, videoH = 480, viewW = 896, viewH = 480) + val expected = Rect(x0 = 0, y0 = 0, w = 896, h = 480) + assertEquals(expected, rect) + } + + @Test + fun sameAspectFourThreeFillsSurface() { + // Видео 4:3 (640×480) в поверхность 4:3 (640×480): полный вьюпорт. + val rect = letterboxRect(videoW = 640, videoH = 480, viewW = 640, viewH = 480) + val expected = Rect(x0 = 0, y0 = 0, w = 640, h = 480) + assertEquals(expected, rect) + } + + @Test + fun zeroSizesReturnNull() { + assertNull(letterboxRect(0, 0, 640, 480), "видео неизвестно — полный вьюпорт") + assertNull(letterboxRect(896, 480, 0, 0), "поверхность не готова — полный вьюпорт") + assertNull(letterboxRect(896, 0, 640, 480), "высота видео неизвестна — полный вьюпорт") + } +}