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