Compare commits

...

3 commits

Author SHA1 Message Date
a2dfa7db3d Make camera image follow device rotation (4-orientation texcoord table)
Re-add landscape support, this time via four precomputed texcoord
buffers — one per Surface.ROTATION_* — instead of going through
SurfaceTexture.getTransformMatrix() (which doesn't honour
Preview.targetRotation for custom SurfaceProviders) or the manual
matrix composition attempts in v1.1.6–1.1.11.

For each device orientation the renderer picks the texcoord set that
both compensates for the 90° CW sensor mount and the activity's own
rotation under screenOrientation="fullSensor", so world-up stays at
clip-space-top. recomputeVertices swaps effective camera dimensions
between portrait and landscape so crop-to-fill picks the right aspect.

Verified empirically in the emulator across all four Display.rotation
values (sky-yellow band always lands at the top of the screen).

Bump to 1.1.12.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 16:12:29 +02:00
b0691adfa3 Reapply "Revert orientation tracking on the camera image"
This reverts commit c0bab85d63.
2026-05-11 15:57:32 +02:00
dd4471c7d2 Revert "Flip rotation-correction angles for both landscape orientations"
This reverts commit 1cd2b0a57c.
2026-05-11 15:57:32 +02:00
7 changed files with 74 additions and 149 deletions

View file

@ -2,10 +2,8 @@ package no.naiv.tiltshift.camera
import android.content.Context import android.content.Context
import android.graphics.SurfaceTexture import android.graphics.SurfaceTexture
import android.hardware.display.DisplayManager
import android.util.Log import android.util.Log
import android.util.Size import android.util.Size
import android.view.Display
import android.view.Surface import android.view.Surface
import androidx.camera.core.Camera import androidx.camera.core.Camera
import androidx.camera.core.CameraSelector import androidx.camera.core.CameraSelector
@ -72,14 +70,6 @@ class CameraManager(private val context: Context) {
/** Weak reference to avoid preventing Activity GC across config changes. */ /** Weak reference to avoid preventing Activity GC across config changes. */
private var lifecycleOwnerRef: WeakReference<LifecycleOwner>? = null private var lifecycleOwnerRef: WeakReference<LifecycleOwner>? = null
/**
* Target rotation passed to CameraX use cases. Drives the SurfaceTexture
* transform matrix and the rotation metadata on captured images.
* Initialized to the display rotation when the camera binds; updated by
* [setTargetRotation] when the device orientation changes.
*/
private var targetRotation: Int = Surface.ROTATION_0
/** /**
* Starts the camera with the given lifecycle owner. * Starts the camera with the given lifecycle owner.
* The surfaceTextureProvider should return the SurfaceTexture from the GL renderer. * The surfaceTextureProvider should return the SurfaceTexture from the GL renderer.
@ -90,13 +80,6 @@ class CameraManager(private val context: Context) {
) { ) {
this.surfaceTextureProvider = surfaceTextureProvider this.surfaceTextureProvider = surfaceTextureProvider
this.lifecycleOwnerRef = WeakReference(lifecycleOwner) this.lifecycleOwnerRef = WeakReference(lifecycleOwner)
// Capture initial display rotation so the very first frame is oriented correctly,
// before the OrientationEventListener has had a chance to fire.
// Note: Context.getDisplay() throws on Application contexts; DisplayManager works
// for any context type and returns the default display.
targetRotation = (context.getSystemService(Context.DISPLAY_SERVICE) as? DisplayManager)
?.getDisplay(Display.DEFAULT_DISPLAY)?.rotation
?: Surface.ROTATION_0
val cameraProviderFuture = ProcessCameraProvider.getInstance(context) val cameraProviderFuture = ProcessCameraProvider.getInstance(context)
cameraProviderFuture.addListener({ cameraProviderFuture.addListener({
@ -127,13 +110,11 @@ class CameraManager(private val context: Context) {
preview = Preview.Builder() preview = Preview.Builder()
.setResolutionSelector(resolutionSelector) .setResolutionSelector(resolutionSelector)
.setTargetRotation(targetRotation)
.build() .build()
// Image capture use case // Image capture use case
val captureBuilder = ImageCapture.Builder() val captureBuilder = ImageCapture.Builder()
.setCaptureMode(ImageCapture.CAPTURE_MODE_MAXIMIZE_QUALITY) .setCaptureMode(ImageCapture.CAPTURE_MODE_MAXIMIZE_QUALITY)
.setTargetRotation(targetRotation)
imageCapture = captureBuilder.build() imageCapture = captureBuilder.build()
@ -192,23 +173,6 @@ class CameraManager(private val context: Context) {
} }
} }
/**
* Updates the target rotation for Preview and ImageCapture use cases.
*
* Rebinds the use cases so CameraX issues a fresh SurfaceRequest with a
* resolution matching the new rotation and a corresponding texture transform
* matrix. Calling `preview.targetRotation = rotation` alone is insufficient
* for a custom SurfaceProvider the new rotation only takes effect on
* subsequently bound streams, leaving the live SurfaceTexture matrix and
* buffer size stale (which made the preview appear locked to the original
* portrait orientation when the device was rotated to landscape).
*/
fun setTargetRotation(rotation: Int) {
if (targetRotation == rotation) return
targetRotation = rotation
lifecycleOwnerRef?.get()?.let { bindCameraUseCases(it) }
}
/** /**
* Sets the zoom ratio. Updates UI state immediately so that rapid pinch-to-zoom * Sets the zoom ratio. Updates UI state immediately so that rapid pinch-to-zoom
* gestures accumulate correctly (each frame uses the latest ratio as its base). * gestures accumulate correctly (each frame uses the latest ratio as its base).

View file

@ -5,7 +5,6 @@ import android.graphics.SurfaceTexture
import android.opengl.GLES11Ext import android.opengl.GLES11Ext
import android.opengl.GLES20 import android.opengl.GLES20
import android.opengl.GLSurfaceView import android.opengl.GLSurfaceView
import android.opengl.Matrix
import android.util.Log import android.util.Log
import android.view.Surface import android.view.Surface
import java.nio.ByteBuffer import java.nio.ByteBuffer
@ -40,8 +39,9 @@ class TiltShiftRenderer(
private var surfaceTexture: SurfaceTexture? = null private var surfaceTexture: SurfaceTexture? = null
private var cameraTextureId: Int = 0 private var cameraTextureId: Int = 0
// Camera quad: crop-to-fill vertices, standard texcoords (rotation comes from texMatrix) // Camera quad: crop-to-fill vertices + rotated texcoords (pass 1 only)
private lateinit var cameraVertexBuffer: FloatBuffer private lateinit var cameraVertexBuffer: FloatBuffer
private lateinit var cameraTexCoordBuffer: FloatBuffer
// Fullscreen quad for blur passes (no crop, standard texcoords) // Fullscreen quad for blur passes (no crop, standard texcoords)
private lateinit var fullscreenVertexBuffer: FloatBuffer private lateinit var fullscreenVertexBuffer: FloatBuffer
@ -55,11 +55,6 @@ class TiltShiftRenderer(
private var fboTexA: Int = 0 private var fboTexA: Int = 0
private var fboTexB: Int = 0 private var fboTexB: Int = 0
// SurfaceTexture transform matrix, refreshed each frame on the GL thread.
private val texMatrix = FloatArray(16)
// Sensor-to-buffer matrix from SurfaceTexture before display-rotation correction.
private val sensorMatrix = FloatArray(16)
// Current effect parameters (updated from UI thread) // Current effect parameters (updated from UI thread)
@Volatile @Volatile
var blurParameters: BlurParameters = BlurParameters.DEFAULT var blurParameters: BlurParameters = BlurParameters.DEFAULT
@ -72,13 +67,38 @@ class TiltShiftRenderer(
private var cameraWidth: Int = 0 private var cameraWidth: Int = 0
@Volatile @Volatile
private var cameraHeight: Int = 0 private var cameraHeight: Int = 0
@Volatile
private var vertexBufferDirty: Boolean = false
// Texture coordinates for the back camera, indexed by Surface.ROTATION_*.
// The base orientation (index 0) applies the 90° CCW rotation that maps
// the landscape sensor frame to a portrait display. Indices 1/2/3 layer
// additional CCW rotations on top so the activity's rotation is
// compensated and world-up stays at clip-space-top.
private val texCoordsBackByRotation = arrayOf(
floatArrayOf(1f, 1f, 1f, 0f, 0f, 1f, 0f, 0f), // ROTATION_0
floatArrayOf(1f, 0f, 0f, 0f, 1f, 1f, 0f, 1f), // ROTATION_90
floatArrayOf(0f, 0f, 0f, 1f, 1f, 0f, 1f, 1f), // ROTATION_180
floatArrayOf(0f, 1f, 1f, 1f, 0f, 0f, 1f, 0f) // ROTATION_270
)
// Front camera variants: same as back, but horizontally mirrored
// for the natural selfie view.
private val texCoordsFrontByRotation = arrayOf(
floatArrayOf(0f, 1f, 0f, 0f, 1f, 1f, 1f, 0f), // ROTATION_0
floatArrayOf(0f, 0f, 1f, 0f, 0f, 1f, 1f, 1f), // ROTATION_90
floatArrayOf(1f, 0f, 1f, 1f, 0f, 0f, 0f, 1f), // ROTATION_180
floatArrayOf(1f, 1f, 0f, 1f, 1f, 0f, 0f, 0f) // ROTATION_270
)
/** Display rotation as a Surface.ROTATION_* constant; affects effective aspect. */
@Volatile @Volatile
private var displayRotation: Int = Surface.ROTATION_0 private var displayRotation: Int = Surface.ROTATION_0
@Volatile @Volatile
private var vertexBufferDirty: Boolean = false private var currentTexCoords = texCoordsBackByRotation[Surface.ROTATION_0]
@Volatile
private var updateTexCoordBuffer = false
override fun onSurfaceCreated(gl: GL10?, config: EGLConfig?) { override fun onSurfaceCreated(gl: GL10?, config: EGLConfig?) {
GLES20.glClearColor(0f, 0f, 0f, 1f) GLES20.glClearColor(0f, 0f, 0f, 1f)
@ -91,8 +111,12 @@ class TiltShiftRenderer(
cameraVertexBuffer.put(floatArrayOf(-1f, -1f, 1f, -1f, -1f, 1f, 1f, 1f)) cameraVertexBuffer.put(floatArrayOf(-1f, -1f, 1f, -1f, -1f, 1f, 1f, 1f))
cameraVertexBuffer.position(0) cameraVertexBuffer.position(0)
// Fullscreen quad for blur passes (standard coords). The same buffer is reused // Camera texcoord buffer (rotated for portrait)
// for the camera passthrough texcoords — rotation is applied via uTexMatrix. cameraTexCoordBuffer = allocateFloatBuffer(8)
cameraTexCoordBuffer.put(currentTexCoords)
cameraTexCoordBuffer.position(0)
// Fullscreen quad for blur passes (standard coords)
fullscreenVertexBuffer = allocateFloatBuffer(8) fullscreenVertexBuffer = allocateFloatBuffer(8)
fullscreenVertexBuffer.put(floatArrayOf(-1f, -1f, 1f, -1f, -1f, 1f, 1f, 1f)) fullscreenVertexBuffer.put(floatArrayOf(-1f, -1f, 1f, -1f, -1f, 1f, 1f, 1f))
fullscreenVertexBuffer.position(0) fullscreenVertexBuffer.position(0)
@ -128,19 +152,20 @@ class TiltShiftRenderer(
} }
override fun onDrawFrame(gl: GL10?) { override fun onDrawFrame(gl: GL10?) {
val st = surfaceTexture surfaceTexture?.updateTexImage()
st?.updateTexImage()
// SurfaceTexture's transform matrix only handles the sensor-to-buffer
// orientation; for a custom SurfaceProvider it does NOT vary with
// Preview.targetRotation. Apply the display-rotation correction here.
st?.getTransformMatrix(sensorMatrix)
composeTexMatrix()
if (vertexBufferDirty) { if (vertexBufferDirty) {
recomputeVertices() recomputeVertices()
vertexBufferDirty = false vertexBufferDirty = false
} }
if (updateTexCoordBuffer) {
cameraTexCoordBuffer.clear()
cameraTexCoordBuffer.put(currentTexCoords)
cameraTexCoordBuffer.position(0)
updateTexCoordBuffer = false
}
val params = blurParameters val params = blurParameters
// --- Pass 1: Camera → FBO-A (passthrough with crop-to-fill) --- // --- Pass 1: Camera → FBO-A (passthrough with crop-to-fill) ---
@ -151,10 +176,10 @@ class TiltShiftRenderer(
) )
GLES20.glViewport(0, 0, surfaceWidth, surfaceHeight) GLES20.glViewport(0, 0, surfaceWidth, surfaceHeight)
GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT) GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT)
shader.usePassthrough(cameraTextureId, texMatrix, isFrontCamera) shader.usePassthrough(cameraTextureId)
drawQuad( drawQuad(
shader.passthroughPositionLoc, shader.passthroughTexCoordLoc, shader.passthroughPositionLoc, shader.passthroughTexCoordLoc,
cameraVertexBuffer, fullscreenTexCoordBuffer cameraVertexBuffer, cameraTexCoordBuffer
) )
// --- Pass 2: FBO-A → FBO-B (horizontal blur) --- // --- Pass 2: FBO-A → FBO-B (horizontal blur) ---
@ -185,7 +210,10 @@ class TiltShiftRenderer(
} }
fun setFrontCamera(front: Boolean) { fun setFrontCamera(front: Boolean) {
if (isFrontCamera != front) {
isFrontCamera = front isFrontCamera = front
refreshTexCoords()
}
} }
fun setCameraResolution(width: Int, height: Int) { fun setCameraResolution(width: Int, height: Int) {
@ -196,47 +224,25 @@ class TiltShiftRenderer(
} }
} }
/** Updates the display rotation so crop-to-fill picks the right effective aspect. */ /**
* Updates the active display rotation. The texture-coordinate buffer is
* rebuilt so the camera image stays world-aligned as the activity rotates
* with the device under screenOrientation="fullSensor", and the
* crop-to-fill math picks the correct effective aspect ratio.
*/
fun setDisplayRotation(rotation: Int) { fun setDisplayRotation(rotation: Int) {
if (displayRotation != rotation) { if (displayRotation != rotation) {
displayRotation = rotation displayRotation = rotation
refreshTexCoords()
vertexBufferDirty = true vertexBufferDirty = true
} }
} }
/** private fun refreshTexCoords() {
* Combines the sensor-to-buffer matrix with a rotation that compensates for val table = if (isFrontCamera) texCoordsFrontByRotation else texCoordsBackByRotation
* the activity's current rotation. The activity rotates with the device val idx = displayRotation.coerceIn(0, table.size - 1)
* (screenOrientation="fullSensor"), so the GL clip-space "up" direction currentTexCoords = table[idx]
* tracks the device rather than the world. To keep world-up at screen-up updateTexCoordBuffer = true
* regardless of orientation, rotate the texcoord sampling pattern by the
* inverse of the activity rotation. Without this correction, the two
* landscape orientations would render the same matrix and one would appear
* upside-down on a real device.
*/
private fun composeTexMatrix() {
// Inverse of the activity rotation: rotating the sampling pattern by
// -activityAngle puts the world-aligned point originally at screen P
// at the same screen P after the activity has rotated.
val angle = when (displayRotation) {
Surface.ROTATION_90 -> 90f
Surface.ROTATION_180 -> 180f
Surface.ROTATION_270 -> -90f
else -> 0f
}
if (angle == 0f) {
System.arraycopy(sensorMatrix, 0, texMatrix, 0, 16)
return
}
// Build rotation around the (0.5, 0.5) texcoord center.
Matrix.setIdentityM(texMatrix, 0)
Matrix.translateM(texMatrix, 0, 0.5f, 0.5f, 0f)
Matrix.rotateM(texMatrix, 0, angle, 0f, 0f, 1f)
Matrix.translateM(texMatrix, 0, -0.5f, -0.5f, 0f)
// texMatrix = sensorMatrix * rotation (rotation is applied to the
// texcoord first, then the sensor-to-buffer transform).
val rot = texMatrix.copyOf()
Matrix.multiplyMM(texMatrix, 0, sensorMatrix, 0, rot, 0)
} }
fun release() { fun release() {
@ -275,11 +281,10 @@ class TiltShiftRenderer(
/** /**
* Recomputes camera vertex positions to achieve crop-to-fill. * Recomputes camera vertex positions to achieve crop-to-fill.
* *
* The camera buffer is the sensor's native landscape resolution. The texMatrix * The camera sensor is landscape; after the orientation-dependent texcoord
* rotates it to match the display, so the *effective* displayed dimensions * rotation, the effective dimensions seen on screen are either swapped
* depend on the display rotation: in portrait the buffer is rotated 90° * (portrait orientations) or kept (landscape orientations). We scale the
* (effective width = cameraHeight), in landscape it is unrotated. * vertex quad so the camera frame fills the surface without stretching
* We scale the vertex quad so the frame fills the surface without stretching
* the GPU clips the overflow. * the GPU clips the overflow.
*/ */
private fun recomputeVertices() { private fun recomputeVertices() {
@ -291,7 +296,6 @@ class TiltShiftRenderer(
displayRotation == Surface.ROTATION_180 displayRotation == Surface.ROTATION_180
val effectiveW = if (isPortrait) cameraHeight else cameraWidth val effectiveW = if (isPortrait) cameraHeight else cameraWidth
val effectiveH = if (isPortrait) cameraWidth else cameraHeight val effectiveH = if (isPortrait) cameraWidth else cameraHeight
val cameraRatio = effectiveW.toFloat() / effectiveH val cameraRatio = effectiveW.toFloat() / effectiveH
val screenRatio = surfaceWidth.toFloat() / surfaceHeight val screenRatio = surfaceWidth.toFloat() / surfaceHeight

View file

@ -20,15 +20,6 @@ import kotlin.math.sin
*/ */
class TiltShiftShader(private val context: Context) { class TiltShiftShader(private val context: Context) {
private companion object {
val IDENTITY_MATRIX = floatArrayOf(
1f, 0f, 0f, 0f,
0f, 1f, 0f, 0f,
0f, 0f, 1f, 0f,
0f, 0f, 0f, 1f
)
}
// --- Passthrough program (camera → FBO) --- // --- Passthrough program (camera → FBO) ---
private var passthroughProgramId: Int = 0 private var passthroughProgramId: Int = 0
@ -38,8 +29,6 @@ class TiltShiftShader(private val context: Context) {
var passthroughTexCoordLoc: Int = 0 var passthroughTexCoordLoc: Int = 0
private set private set
private var passthroughTextureLoc: Int = 0 private var passthroughTextureLoc: Int = 0
private var passthroughTexMatrixLoc: Int = 0
private var passthroughMirrorLoc: Int = 0
// --- Blur program (FBO → FBO/screen) --- // --- Blur program (FBO → FBO/screen) ---
@ -50,8 +39,6 @@ class TiltShiftShader(private val context: Context) {
var blurTexCoordLoc: Int = 0 var blurTexCoordLoc: Int = 0
private set private set
private var blurTextureLoc: Int = 0 private var blurTextureLoc: Int = 0
private var blurTexMatrixLoc: Int = 0
private var blurMirrorLoc: Int = 0
private var blurModeLoc: Int = 0 private var blurModeLoc: Int = 0
private var blurPositionXLoc: Int = 0 private var blurPositionXLoc: Int = 0
private var blurPositionYLoc: Int = 0 private var blurPositionYLoc: Int = 0
@ -81,8 +68,6 @@ class TiltShiftShader(private val context: Context) {
passthroughPositionLoc = GLES20.glGetAttribLocation(passthroughProgramId, "aPosition") passthroughPositionLoc = GLES20.glGetAttribLocation(passthroughProgramId, "aPosition")
passthroughTexCoordLoc = GLES20.glGetAttribLocation(passthroughProgramId, "aTexCoord") passthroughTexCoordLoc = GLES20.glGetAttribLocation(passthroughProgramId, "aTexCoord")
passthroughTextureLoc = GLES20.glGetUniformLocation(passthroughProgramId, "uTexture") passthroughTextureLoc = GLES20.glGetUniformLocation(passthroughProgramId, "uTexture")
passthroughTexMatrixLoc = GLES20.glGetUniformLocation(passthroughProgramId, "uTexMatrix")
passthroughMirrorLoc = GLES20.glGetUniformLocation(passthroughProgramId, "uMirrorX")
// Blur program // Blur program
val blurFragSource = loadShaderSource(R.raw.tiltshift_fragment) val blurFragSource = loadShaderSource(R.raw.tiltshift_fragment)
@ -93,8 +78,6 @@ class TiltShiftShader(private val context: Context) {
blurPositionLoc = GLES20.glGetAttribLocation(blurProgramId, "aPosition") blurPositionLoc = GLES20.glGetAttribLocation(blurProgramId, "aPosition")
blurTexCoordLoc = GLES20.glGetAttribLocation(blurProgramId, "aTexCoord") blurTexCoordLoc = GLES20.glGetAttribLocation(blurProgramId, "aTexCoord")
blurTextureLoc = GLES20.glGetUniformLocation(blurProgramId, "uTexture") blurTextureLoc = GLES20.glGetUniformLocation(blurProgramId, "uTexture")
blurTexMatrixLoc = GLES20.glGetUniformLocation(blurProgramId, "uTexMatrix")
blurMirrorLoc = GLES20.glGetUniformLocation(blurProgramId, "uMirrorX")
blurModeLoc = GLES20.glGetUniformLocation(blurProgramId, "uMode") blurModeLoc = GLES20.glGetUniformLocation(blurProgramId, "uMode")
blurPositionXLoc = GLES20.glGetUniformLocation(blurProgramId, "uPositionX") blurPositionXLoc = GLES20.glGetUniformLocation(blurProgramId, "uPositionX")
blurPositionYLoc = GLES20.glGetUniformLocation(blurProgramId, "uPositionY") blurPositionYLoc = GLES20.glGetUniformLocation(blurProgramId, "uPositionY")
@ -113,19 +96,12 @@ class TiltShiftShader(private val context: Context) {
/** /**
* Activates the passthrough program and binds the camera texture. * Activates the passthrough program and binds the camera texture.
*
* @param cameraTextureId The OES texture receiving camera frames.
* @param texMatrix 4x4 transform from SurfaceTexture.getTransformMatrix()
* encodes sensor-to-display rotation and Y-flip.
* @param mirrorX true to horizontally mirror (front camera selfie view).
*/ */
fun usePassthrough(cameraTextureId: Int, texMatrix: FloatArray, mirrorX: Boolean) { fun usePassthrough(cameraTextureId: Int) {
GLES20.glUseProgram(passthroughProgramId) GLES20.glUseProgram(passthroughProgramId)
GLES20.glActiveTexture(GLES20.GL_TEXTURE0) GLES20.glActiveTexture(GLES20.GL_TEXTURE0)
GLES20.glBindTexture(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, cameraTextureId) GLES20.glBindTexture(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, cameraTextureId)
GLES20.glUniform1i(passthroughTextureLoc, 0) GLES20.glUniform1i(passthroughTextureLoc, 0)
GLES20.glUniformMatrix4fv(passthroughTexMatrixLoc, 1, false, texMatrix, 0)
GLES20.glUniform1f(passthroughMirrorLoc, if (mirrorX) 1f else 0f)
} }
/** /**
@ -152,10 +128,6 @@ class TiltShiftShader(private val context: Context) {
GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, fboTextureId) GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, fboTextureId)
GLES20.glUniform1i(blurTextureLoc, 0) GLES20.glUniform1i(blurTextureLoc, 0)
// FBO content is already in display orientation — pass identity matrix and no mirror.
GLES20.glUniformMatrix4fv(blurTexMatrixLoc, 1, false, IDENTITY_MATRIX, 0)
GLES20.glUniform1f(blurMirrorLoc, 0f)
GLES20.glUniform1i(blurModeLoc, if (params.mode == BlurMode.RADIAL) 1 else 0) GLES20.glUniform1i(blurModeLoc, if (params.mode == BlurMode.RADIAL) 1 else 0)
GLES20.glUniform1f(blurPositionXLoc, params.positionX) GLES20.glUniform1f(blurPositionXLoc, params.positionX)
GLES20.glUniform1f(blurPositionYLoc, params.positionY) GLES20.glUniform1f(blurPositionYLoc, params.positionY)

View file

@ -112,7 +112,6 @@ fun CameraScreen(
val isFrontCamera by viewModel.cameraManager.isFrontCamera.collectAsState() val isFrontCamera by viewModel.cameraManager.isFrontCamera.collectAsState()
val previewResolution by viewModel.cameraManager.previewResolution.collectAsState() val previewResolution by viewModel.cameraManager.previewResolution.collectAsState()
val cameraError by viewModel.cameraManager.error.collectAsState() val cameraError by viewModel.cameraManager.error.collectAsState()
val currentRotation by viewModel.currentRotation.collectAsState()
// Gallery picker // Gallery picker
val galleryLauncher = rememberLauncherForActivityResult( val galleryLauncher = rememberLauncherForActivityResult(
@ -165,10 +164,11 @@ fun CameraScreen(
} }
} }
// Forward device rotation to renderer (aspect math) and CameraX (target rotation) // Forward device rotation to renderer so the camera image stays
// world-aligned as the activity rotates with the device.
val currentRotation by viewModel.currentRotation.collectAsState()
LaunchedEffect(currentRotation, renderer) { LaunchedEffect(currentRotation, renderer) {
renderer?.setDisplayRotation(currentRotation) renderer?.setDisplayRotation(currentRotation)
viewModel.cameraManager.setTargetRotation(currentRotation)
glSurfaceView?.requestRender() glSurfaceView?.requestRender()
} }

View file

@ -12,10 +12,7 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.RestartAlt import androidx.compose.material.icons.filled.RestartAlt
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
@ -113,10 +110,8 @@ fun ControlPanel(
Column( Column(
modifier = modifier modifier = modifier
.width(200.dp) .width(200.dp)
.wrapContentHeight()
.clip(RoundedCornerShape(16.dp)) .clip(RoundedCornerShape(16.dp))
.background(AppColors.OverlayDarker) .background(AppColors.OverlayDarker)
.verticalScroll(rememberScrollState())
.padding(16.dp), .padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp) verticalArrangement = Arrangement.spacedBy(12.dp)
) { ) {

View file

@ -1,22 +1,12 @@
// Vertex shader for tilt-shift effect. // Vertex shader for tilt-shift effect
// // Passes through position and calculates texture coordinates
// uTexMatrix: applied to texcoords. For the passthrough pass it carries the
// SurfaceTexture transform (sensor → display rotation, plus Y-flip). For the
// blur passes it is identity.
// uMirrorX: 1.0 to horizontally mirror texcoords (front-camera selfie view),
// 0.0 otherwise. Applied AFTER uTexMatrix.
attribute vec4 aPosition; attribute vec4 aPosition;
attribute vec2 aTexCoord; attribute vec2 aTexCoord;
uniform mat4 uTexMatrix;
uniform float uMirrorX;
varying vec2 vTexCoord; varying vec2 vTexCoord;
void main() { void main() {
gl_Position = aPosition; gl_Position = aPosition;
vec2 tc = (uTexMatrix * vec4(aTexCoord, 0.0, 1.0)).xy; vTexCoord = aTexCoord;
if (uMirrorX > 0.5) tc.x = 1.0 - tc.x;
vTexCoord = tc;
} }

View file

@ -1,4 +1,4 @@
versionMajor=1 versionMajor=1
versionMinor=1 versionMinor=1
versionPatch=11 versionPatch=12
versionCode=13 versionCode=14