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.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<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(
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()
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)
}
}
@@ -65,7 +65,7 @@ fun GlassesMovieScreen(
}
}
Binocular(scale = true) {
Binocular(scale = false) {
val screenWidthPx = BinocularScreenSize.current.width
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), "высота видео неизвестна — полный вьюпорт")
}
}