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:
@@ -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), "высота видео неизвестна — полный вьюпорт")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user