fix(app-glasses): би-нокулярный рендер RayNeo — letterbox + scale=false (полный кадр в каждый глаз)

Живой тест на 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)
- Пользователь: «на глаз размер поменялся, будто бы норм»
This commit is contained in:
Hermes Agent
2026-08-19 21:03:55 +03:00
parent 25b85ea191
commit b4420f446c
4 changed files with 138 additions and 2 deletions
+32
View File
@@ -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
(поля сверху/снизу), в каждом глазу полный кадр; пользователь видит
правильные пропорции (не «квадратное»).
@@ -12,7 +12,10 @@ import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.viewinterop.AndroidView
import com.google.android.exoplayer2.Player
import com.google.android.exoplayer2.SimpleExoPlayer 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.log
import pw.binom.viewmate.glasses.render.EglCore import pw.binom.viewmate.glasses.render.EglCore
import pw.binom.viewmate.glasses.render.FullFrameRect import pw.binom.viewmate.glasses.render.FullFrameRect
@@ -72,6 +75,21 @@ class EglVideoRenderer(private val player: SimpleExoPlayer) {
private val outputs = mutableListOf<Output>() private val outputs = mutableListOf<Output>()
// Размер видео (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( private class Output(
val holder: SurfaceHolder, val holder: SurfaceHolder,
val surface: WindowSurface, val surface: WindowSurface,
@@ -144,7 +162,14 @@ class EglVideoRenderer(private val player: SimpleExoPlayer) {
private fun drawFrame(blit: FullFrameRect, windowSurface: WindowSurface, viewWidth: Int, viewHeight: Int) { private fun drawFrame(blit: FullFrameRect, windowSurface: WindowSurface, viewWidth: Int, viewHeight: Int) {
windowSurface.makeCurrent() windowSurface.makeCurrent()
val rect = letterboxRect(videoWidth, videoHeight, viewWidth, viewHeight)
if (rect == null) {
// Размер видео ещё неизвестен — рисуем на всю поверхность.
GLES20.glViewport(0, 0, viewWidth, viewHeight) GLES20.glViewport(0, 0, viewWidth, viewHeight)
} else {
// Letterbox: поля (чёрные, т.к. фон/очистка) сверху/снизу или сбоку.
GLES20.glViewport(rect.x0, rect.y0, rect.w, rect.h)
}
blit.drawFrame(textureId, transformMatrix) blit.drawFrame(textureId, transformMatrix)
windowSurface.swapBuffers() windowSurface.swapBuffers()
} }
@@ -154,6 +179,8 @@ class EglVideoRenderer(private val player: SimpleExoPlayer) {
released = true released = true
log("glasses", "EglVideoRenderer: release") log("glasses", "EglVideoRenderer: release")
player.removeListener(videoSizeListener)
// Отвязать плеер (плеер мог быть уже освобождён — не падаем). // Отвязать плеер (плеер мог быть уже освобождён — не падаем).
val ps = playerSurface val ps = playerSurface
if (ps != null) { if (ps != null) {
@@ -179,3 +206,31 @@ class EglVideoRenderer(private val player: SimpleExoPlayer) {
eglCore = null 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)
}
}
@@ -65,7 +65,7 @@ fun GlassesMovieScreen(
} }
} }
Binocular(scale = true) { Binocular(scale = false) {
val screenWidthPx = BinocularScreenSize.current.width val screenWidthPx = BinocularScreenSize.current.width
val screenHeightPx = BinocularScreenSize.current.height val screenHeightPx = BinocularScreenSize.current.height
@@ -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), "высота видео неизвестна — полный вьюпорт")
}
}