From c0bab85d63d66180400ae88d3a5bf5eda5e97ee5 Mon Sep 17 00:00:00 2001 From: Ole-Morten Duesund Date: Mon, 11 May 2026 15:29:59 +0200 Subject: [PATCH 1/9] Revert "Revert orientation tracking on the camera image" This reverts commit 4f8661f648ca80b5602eda6129d7ec72369ab425. --- .../no/naiv/tiltshift/camera/CameraManager.kt | 36 +++++ .../tiltshift/effect/TiltShiftRenderer.kt | 131 +++++++++++------- .../naiv/tiltshift/effect/TiltShiftShader.kt | 30 +++- .../java/no/naiv/tiltshift/ui/CameraScreen.kt | 8 ++ .../java/no/naiv/tiltshift/ui/ControlPanel.kt | 5 + app/src/main/res/raw/tiltshift_vertex.glsl | 16 ++- version.properties | 4 +- 7 files changed, 173 insertions(+), 57 deletions(-) diff --git a/app/src/main/java/no/naiv/tiltshift/camera/CameraManager.kt b/app/src/main/java/no/naiv/tiltshift/camera/CameraManager.kt index 51d42ed..7f7bed5 100644 --- a/app/src/main/java/no/naiv/tiltshift/camera/CameraManager.kt +++ b/app/src/main/java/no/naiv/tiltshift/camera/CameraManager.kt @@ -2,8 +2,10 @@ package no.naiv.tiltshift.camera import android.content.Context import android.graphics.SurfaceTexture +import android.hardware.display.DisplayManager import android.util.Log import android.util.Size +import android.view.Display import android.view.Surface import androidx.camera.core.Camera import androidx.camera.core.CameraSelector @@ -70,6 +72,14 @@ class CameraManager(private val context: Context) { /** Weak reference to avoid preventing Activity GC across config changes. */ private var lifecycleOwnerRef: WeakReference? = 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. * The surfaceTextureProvider should return the SurfaceTexture from the GL renderer. @@ -80,6 +90,13 @@ class CameraManager(private val context: Context) { ) { this.surfaceTextureProvider = surfaceTextureProvider 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) cameraProviderFuture.addListener({ @@ -110,11 +127,13 @@ class CameraManager(private val context: Context) { preview = Preview.Builder() .setResolutionSelector(resolutionSelector) + .setTargetRotation(targetRotation) .build() // Image capture use case val captureBuilder = ImageCapture.Builder() .setCaptureMode(ImageCapture.CAPTURE_MODE_MAXIMIZE_QUALITY) + .setTargetRotation(targetRotation) imageCapture = captureBuilder.build() @@ -173,6 +192,23 @@ 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 * gestures accumulate correctly (each frame uses the latest ratio as its base). diff --git a/app/src/main/java/no/naiv/tiltshift/effect/TiltShiftRenderer.kt b/app/src/main/java/no/naiv/tiltshift/effect/TiltShiftRenderer.kt index d170d58..e8ff4bd 100644 --- a/app/src/main/java/no/naiv/tiltshift/effect/TiltShiftRenderer.kt +++ b/app/src/main/java/no/naiv/tiltshift/effect/TiltShiftRenderer.kt @@ -5,7 +5,9 @@ import android.graphics.SurfaceTexture import android.opengl.GLES11Ext import android.opengl.GLES20 import android.opengl.GLSurfaceView +import android.opengl.Matrix import android.util.Log +import android.view.Surface import java.nio.ByteBuffer import java.nio.ByteOrder import java.nio.FloatBuffer @@ -38,9 +40,8 @@ class TiltShiftRenderer( private var surfaceTexture: SurfaceTexture? = null private var cameraTextureId: Int = 0 - // Camera quad: crop-to-fill vertices + rotated texcoords (pass 1 only) + // Camera quad: crop-to-fill vertices, standard texcoords (rotation comes from texMatrix) private lateinit var cameraVertexBuffer: FloatBuffer - private lateinit var cameraTexCoordBuffer: FloatBuffer // Fullscreen quad for blur passes (no crop, standard texcoords) private lateinit var fullscreenVertexBuffer: FloatBuffer @@ -54,6 +55,11 @@ class TiltShiftRenderer( private var fboTexA: 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) @Volatile var blurParameters: BlurParameters = BlurParameters.DEFAULT @@ -66,33 +72,14 @@ class TiltShiftRenderer( private var cameraWidth: Int = 0 @Volatile private var cameraHeight: Int = 0 + + /** Display rotation as a Surface.ROTATION_* constant; affects effective aspect. */ + @Volatile + private var displayRotation: Int = Surface.ROTATION_0 + @Volatile private var vertexBufferDirty: Boolean = false - // Texture coordinates rotated 90° for portrait mode (back camera) - // (Camera sensors are landscape-oriented, we rotate to portrait) - private val texCoordsBack = floatArrayOf( - 1f, 1f, // Bottom left of screen -> bottom right of texture - 1f, 0f, // Bottom right of screen -> top right of texture - 0f, 1f, // Top left of screen -> bottom left of texture - 0f, 0f // Top right of screen -> top left of texture - ) - - // Texture coordinates for front camera (mirrored + rotated) - // Front camera needs horizontal mirror for natural selfie view - private val texCoordsFront = floatArrayOf( - 0f, 1f, // Bottom left of screen - 0f, 0f, // Bottom right of screen - 1f, 1f, // Top left of screen - 1f, 0f // Top right of screen - ) - - @Volatile - private var currentTexCoords = texCoordsBack - - @Volatile - private var updateTexCoordBuffer = false - override fun onSurfaceCreated(gl: GL10?, config: EGLConfig?) { GLES20.glClearColor(0f, 0f, 0f, 1f) @@ -104,12 +91,8 @@ class TiltShiftRenderer( cameraVertexBuffer.put(floatArrayOf(-1f, -1f, 1f, -1f, -1f, 1f, 1f, 1f)) cameraVertexBuffer.position(0) - // Camera texcoord buffer (rotated for portrait) - cameraTexCoordBuffer = allocateFloatBuffer(8) - cameraTexCoordBuffer.put(currentTexCoords) - cameraTexCoordBuffer.position(0) - - // Fullscreen quad for blur passes (standard coords) + // Fullscreen quad for blur passes (standard coords). The same buffer is reused + // for the camera passthrough texcoords — rotation is applied via uTexMatrix. fullscreenVertexBuffer = allocateFloatBuffer(8) fullscreenVertexBuffer.put(floatArrayOf(-1f, -1f, 1f, -1f, -1f, 1f, 1f, 1f)) fullscreenVertexBuffer.position(0) @@ -145,20 +128,19 @@ class TiltShiftRenderer( } override fun onDrawFrame(gl: GL10?) { - surfaceTexture?.updateTexImage() + val st = surfaceTexture + 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) { recomputeVertices() vertexBufferDirty = false } - if (updateTexCoordBuffer) { - cameraTexCoordBuffer.clear() - cameraTexCoordBuffer.put(currentTexCoords) - cameraTexCoordBuffer.position(0) - updateTexCoordBuffer = false - } - val params = blurParameters // --- Pass 1: Camera → FBO-A (passthrough with crop-to-fill) --- @@ -169,10 +151,10 @@ class TiltShiftRenderer( ) GLES20.glViewport(0, 0, surfaceWidth, surfaceHeight) GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT) - shader.usePassthrough(cameraTextureId) + shader.usePassthrough(cameraTextureId, texMatrix, isFrontCamera) drawQuad( shader.passthroughPositionLoc, shader.passthroughTexCoordLoc, - cameraVertexBuffer, cameraTexCoordBuffer + cameraVertexBuffer, fullscreenTexCoordBuffer ) // --- Pass 2: FBO-A → FBO-B (horizontal blur) --- @@ -203,11 +185,7 @@ class TiltShiftRenderer( } fun setFrontCamera(front: Boolean) { - if (isFrontCamera != front) { - isFrontCamera = front - currentTexCoords = if (front) texCoordsFront else texCoordsBack - updateTexCoordBuffer = true - } + isFrontCamera = front } fun setCameraResolution(width: Int, height: Int) { @@ -218,6 +196,49 @@ class TiltShiftRenderer( } } + /** Updates the display rotation so crop-to-fill picks the right effective aspect. */ + fun setDisplayRotation(rotation: Int) { + if (displayRotation != rotation) { + displayRotation = rotation + vertexBufferDirty = true + } + } + + /** + * Combines the sensor-to-buffer matrix with a rotation that compensates for + * the activity's current rotation. The activity rotates with the device + * (screenOrientation="fullSensor"), so the GL clip-space "up" direction + * tracks the device rather than the world. To keep world-up at screen-up + * 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() { shader.release() surfaceTexture?.release() @@ -254,16 +275,24 @@ class TiltShiftRenderer( /** * Recomputes camera vertex positions to achieve crop-to-fill. * - * The camera sensor is landscape; after the 90° rotation applied via texture coordinates, - * the effective portrait dimensions are (cameraHeight × cameraWidth). We scale the vertex - * quad so the camera frame fills the surface without stretching — the GPU clips the overflow. + * The camera buffer is the sensor's native landscape resolution. The texMatrix + * rotates it to match the display, so the *effective* displayed dimensions + * depend on the display rotation: in portrait the buffer is rotated 90° + * (effective width = cameraHeight), in landscape it is unrotated. + * We scale the vertex quad so the frame fills the surface without stretching — + * the GPU clips the overflow. */ private fun recomputeVertices() { var scaleX = 1f var scaleY = 1f if (cameraWidth > 0 && cameraHeight > 0 && surfaceWidth > 0 && surfaceHeight > 0) { - val cameraRatio = cameraHeight.toFloat() / cameraWidth + val isPortrait = displayRotation == Surface.ROTATION_0 || + displayRotation == Surface.ROTATION_180 + val effectiveW = if (isPortrait) cameraHeight else cameraWidth + val effectiveH = if (isPortrait) cameraWidth else cameraHeight + + val cameraRatio = effectiveW.toFloat() / effectiveH val screenRatio = surfaceWidth.toFloat() / surfaceHeight if (cameraRatio > screenRatio) { diff --git a/app/src/main/java/no/naiv/tiltshift/effect/TiltShiftShader.kt b/app/src/main/java/no/naiv/tiltshift/effect/TiltShiftShader.kt index cdafd8f..ebbfb18 100644 --- a/app/src/main/java/no/naiv/tiltshift/effect/TiltShiftShader.kt +++ b/app/src/main/java/no/naiv/tiltshift/effect/TiltShiftShader.kt @@ -20,6 +20,15 @@ import kotlin.math.sin */ 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) --- private var passthroughProgramId: Int = 0 @@ -29,6 +38,8 @@ class TiltShiftShader(private val context: Context) { var passthroughTexCoordLoc: Int = 0 private set private var passthroughTextureLoc: Int = 0 + private var passthroughTexMatrixLoc: Int = 0 + private var passthroughMirrorLoc: Int = 0 // --- Blur program (FBO → FBO/screen) --- @@ -39,6 +50,8 @@ class TiltShiftShader(private val context: Context) { var blurTexCoordLoc: Int = 0 private set private var blurTextureLoc: Int = 0 + private var blurTexMatrixLoc: Int = 0 + private var blurMirrorLoc: Int = 0 private var blurModeLoc: Int = 0 private var blurPositionXLoc: Int = 0 private var blurPositionYLoc: Int = 0 @@ -68,6 +81,8 @@ class TiltShiftShader(private val context: Context) { passthroughPositionLoc = GLES20.glGetAttribLocation(passthroughProgramId, "aPosition") passthroughTexCoordLoc = GLES20.glGetAttribLocation(passthroughProgramId, "aTexCoord") passthroughTextureLoc = GLES20.glGetUniformLocation(passthroughProgramId, "uTexture") + passthroughTexMatrixLoc = GLES20.glGetUniformLocation(passthroughProgramId, "uTexMatrix") + passthroughMirrorLoc = GLES20.glGetUniformLocation(passthroughProgramId, "uMirrorX") // Blur program val blurFragSource = loadShaderSource(R.raw.tiltshift_fragment) @@ -78,6 +93,8 @@ class TiltShiftShader(private val context: Context) { blurPositionLoc = GLES20.glGetAttribLocation(blurProgramId, "aPosition") blurTexCoordLoc = GLES20.glGetAttribLocation(blurProgramId, "aTexCoord") blurTextureLoc = GLES20.glGetUniformLocation(blurProgramId, "uTexture") + blurTexMatrixLoc = GLES20.glGetUniformLocation(blurProgramId, "uTexMatrix") + blurMirrorLoc = GLES20.glGetUniformLocation(blurProgramId, "uMirrorX") blurModeLoc = GLES20.glGetUniformLocation(blurProgramId, "uMode") blurPositionXLoc = GLES20.glGetUniformLocation(blurProgramId, "uPositionX") blurPositionYLoc = GLES20.glGetUniformLocation(blurProgramId, "uPositionY") @@ -96,12 +113,19 @@ class TiltShiftShader(private val context: Context) { /** * 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) { + fun usePassthrough(cameraTextureId: Int, texMatrix: FloatArray, mirrorX: Boolean) { GLES20.glUseProgram(passthroughProgramId) GLES20.glActiveTexture(GLES20.GL_TEXTURE0) GLES20.glBindTexture(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, cameraTextureId) GLES20.glUniform1i(passthroughTextureLoc, 0) + GLES20.glUniformMatrix4fv(passthroughTexMatrixLoc, 1, false, texMatrix, 0) + GLES20.glUniform1f(passthroughMirrorLoc, if (mirrorX) 1f else 0f) } /** @@ -128,6 +152,10 @@ class TiltShiftShader(private val context: Context) { GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, fboTextureId) 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.glUniform1f(blurPositionXLoc, params.positionX) GLES20.glUniform1f(blurPositionYLoc, params.positionY) diff --git a/app/src/main/java/no/naiv/tiltshift/ui/CameraScreen.kt b/app/src/main/java/no/naiv/tiltshift/ui/CameraScreen.kt index bbb706d..167b376 100644 --- a/app/src/main/java/no/naiv/tiltshift/ui/CameraScreen.kt +++ b/app/src/main/java/no/naiv/tiltshift/ui/CameraScreen.kt @@ -112,6 +112,7 @@ fun CameraScreen( val isFrontCamera by viewModel.cameraManager.isFrontCamera.collectAsState() val previewResolution by viewModel.cameraManager.previewResolution.collectAsState() val cameraError by viewModel.cameraManager.error.collectAsState() + val currentRotation by viewModel.currentRotation.collectAsState() // Gallery picker val galleryLauncher = rememberLauncherForActivityResult( @@ -164,6 +165,13 @@ fun CameraScreen( } } + // Forward device rotation to renderer (aspect math) and CameraX (target rotation) + LaunchedEffect(currentRotation, renderer) { + renderer?.setDisplayRotation(currentRotation) + viewModel.cameraManager.setTargetRotation(currentRotation) + glSurfaceView?.requestRender() + } + // Start camera when surface texture is available LaunchedEffect(surfaceTexture) { surfaceTexture?.let { diff --git a/app/src/main/java/no/naiv/tiltshift/ui/ControlPanel.kt b/app/src/main/java/no/naiv/tiltshift/ui/ControlPanel.kt index ad94ad0..dbc5112 100644 --- a/app/src/main/java/no/naiv/tiltshift/ui/ControlPanel.kt +++ b/app/src/main/java/no/naiv/tiltshift/ui/ControlPanel.kt @@ -12,7 +12,10 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size 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.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.RestartAlt import androidx.compose.material3.Icon @@ -110,8 +113,10 @@ fun ControlPanel( Column( modifier = modifier .width(200.dp) + .wrapContentHeight() .clip(RoundedCornerShape(16.dp)) .background(AppColors.OverlayDarker) + .verticalScroll(rememberScrollState()) .padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp) ) { diff --git a/app/src/main/res/raw/tiltshift_vertex.glsl b/app/src/main/res/raw/tiltshift_vertex.glsl index e009424..ca79319 100644 --- a/app/src/main/res/raw/tiltshift_vertex.glsl +++ b/app/src/main/res/raw/tiltshift_vertex.glsl @@ -1,12 +1,22 @@ -// Vertex shader for tilt-shift effect -// Passes through position and calculates texture coordinates +// Vertex shader for tilt-shift effect. +// +// 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 vec2 aTexCoord; +uniform mat4 uTexMatrix; +uniform float uMirrorX; + varying vec2 vTexCoord; void main() { gl_Position = aPosition; - vTexCoord = aTexCoord; + vec2 tc = (uTexMatrix * vec4(aTexCoord, 0.0, 1.0)).xy; + if (uMirrorX > 0.5) tc.x = 1.0 - tc.x; + vTexCoord = tc; } diff --git a/version.properties b/version.properties index ba10bb5..34449a3 100644 --- a/version.properties +++ b/version.properties @@ -1,4 +1,4 @@ versionMajor=1 versionMinor=1 -versionPatch=10 -versionCode=12 +versionPatch=9 +versionCode=11 From 1cd2b0a57c7ad2afa3a555d8dfe549268c1896e8 Mon Sep 17 00:00:00 2001 From: Ole-Morten Duesund Date: Mon, 11 May 2026 15:46:31 +0200 Subject: [PATCH 2/9] Flip rotation-correction angles for both landscape orientations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bring back the v1.1.9 approach (apply an inverse rotation to the texcoord sampling pattern so the camera image stays world-aligned through device rotation), but with the right signs this time. The previous angles were 180° off for both landscape orientations and showed the camera content upside-down on a real device. Verified each Display.rotation against the emulator's virtual scene (sky-yellow → road-brown → buildings-dark): the sky/yellow band now sits at the top of the screen in all four orientations. Bump to 1.1.11. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../main/java/no/naiv/tiltshift/effect/TiltShiftRenderer.kt | 4 ++-- version.properties | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/no/naiv/tiltshift/effect/TiltShiftRenderer.kt b/app/src/main/java/no/naiv/tiltshift/effect/TiltShiftRenderer.kt index e8ff4bd..f7f247e 100644 --- a/app/src/main/java/no/naiv/tiltshift/effect/TiltShiftRenderer.kt +++ b/app/src/main/java/no/naiv/tiltshift/effect/TiltShiftRenderer.kt @@ -219,9 +219,9 @@ class TiltShiftRenderer( // -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_90 -> 90f Surface.ROTATION_180 -> 180f - Surface.ROTATION_270 -> 90f + Surface.ROTATION_270 -> -90f else -> 0f } if (angle == 0f) { diff --git a/version.properties b/version.properties index 34449a3..6ca2659 100644 --- a/version.properties +++ b/version.properties @@ -1,4 +1,4 @@ versionMajor=1 versionMinor=1 -versionPatch=9 -versionCode=11 +versionPatch=11 +versionCode=13 From dd4471c7d217725f8dcf961db92ea04777180b4a Mon Sep 17 00:00:00 2001 From: Ole-Morten Duesund Date: Mon, 11 May 2026 15:57:32 +0200 Subject: [PATCH 3/9] Revert "Flip rotation-correction angles for both landscape orientations" This reverts commit 1cd2b0a57c7ad2afa3a555d8dfe549268c1896e8. --- .../main/java/no/naiv/tiltshift/effect/TiltShiftRenderer.kt | 4 ++-- version.properties | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/no/naiv/tiltshift/effect/TiltShiftRenderer.kt b/app/src/main/java/no/naiv/tiltshift/effect/TiltShiftRenderer.kt index f7f247e..e8ff4bd 100644 --- a/app/src/main/java/no/naiv/tiltshift/effect/TiltShiftRenderer.kt +++ b/app/src/main/java/no/naiv/tiltshift/effect/TiltShiftRenderer.kt @@ -219,9 +219,9 @@ class TiltShiftRenderer( // -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_90 -> -90f Surface.ROTATION_180 -> 180f - Surface.ROTATION_270 -> -90f + Surface.ROTATION_270 -> 90f else -> 0f } if (angle == 0f) { diff --git a/version.properties b/version.properties index 6ca2659..34449a3 100644 --- a/version.properties +++ b/version.properties @@ -1,4 +1,4 @@ versionMajor=1 versionMinor=1 -versionPatch=11 -versionCode=13 +versionPatch=9 +versionCode=11 From b0691adfa384bcee9acaa0437b18f7dae35b881b Mon Sep 17 00:00:00 2001 From: Ole-Morten Duesund Date: Mon, 11 May 2026 15:57:32 +0200 Subject: [PATCH 4/9] Reapply "Revert orientation tracking on the camera image" This reverts commit c0bab85d63d66180400ae88d3a5bf5eda5e97ee5. --- .../no/naiv/tiltshift/camera/CameraManager.kt | 36 ----- .../tiltshift/effect/TiltShiftRenderer.kt | 131 +++++++----------- .../naiv/tiltshift/effect/TiltShiftShader.kt | 30 +--- .../java/no/naiv/tiltshift/ui/CameraScreen.kt | 8 -- .../java/no/naiv/tiltshift/ui/ControlPanel.kt | 5 - app/src/main/res/raw/tiltshift_vertex.glsl | 16 +-- version.properties | 4 +- 7 files changed, 57 insertions(+), 173 deletions(-) diff --git a/app/src/main/java/no/naiv/tiltshift/camera/CameraManager.kt b/app/src/main/java/no/naiv/tiltshift/camera/CameraManager.kt index 7f7bed5..51d42ed 100644 --- a/app/src/main/java/no/naiv/tiltshift/camera/CameraManager.kt +++ b/app/src/main/java/no/naiv/tiltshift/camera/CameraManager.kt @@ -2,10 +2,8 @@ package no.naiv.tiltshift.camera import android.content.Context import android.graphics.SurfaceTexture -import android.hardware.display.DisplayManager import android.util.Log import android.util.Size -import android.view.Display import android.view.Surface import androidx.camera.core.Camera 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. */ private var lifecycleOwnerRef: WeakReference? = 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. * The surfaceTextureProvider should return the SurfaceTexture from the GL renderer. @@ -90,13 +80,6 @@ class CameraManager(private val context: Context) { ) { this.surfaceTextureProvider = surfaceTextureProvider 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) cameraProviderFuture.addListener({ @@ -127,13 +110,11 @@ class CameraManager(private val context: Context) { preview = Preview.Builder() .setResolutionSelector(resolutionSelector) - .setTargetRotation(targetRotation) .build() // Image capture use case val captureBuilder = ImageCapture.Builder() .setCaptureMode(ImageCapture.CAPTURE_MODE_MAXIMIZE_QUALITY) - .setTargetRotation(targetRotation) 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 * gestures accumulate correctly (each frame uses the latest ratio as its base). diff --git a/app/src/main/java/no/naiv/tiltshift/effect/TiltShiftRenderer.kt b/app/src/main/java/no/naiv/tiltshift/effect/TiltShiftRenderer.kt index e8ff4bd..d170d58 100644 --- a/app/src/main/java/no/naiv/tiltshift/effect/TiltShiftRenderer.kt +++ b/app/src/main/java/no/naiv/tiltshift/effect/TiltShiftRenderer.kt @@ -5,9 +5,7 @@ import android.graphics.SurfaceTexture import android.opengl.GLES11Ext import android.opengl.GLES20 import android.opengl.GLSurfaceView -import android.opengl.Matrix import android.util.Log -import android.view.Surface import java.nio.ByteBuffer import java.nio.ByteOrder import java.nio.FloatBuffer @@ -40,8 +38,9 @@ class TiltShiftRenderer( private var surfaceTexture: SurfaceTexture? = null 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 cameraTexCoordBuffer: FloatBuffer // Fullscreen quad for blur passes (no crop, standard texcoords) private lateinit var fullscreenVertexBuffer: FloatBuffer @@ -55,11 +54,6 @@ class TiltShiftRenderer( private var fboTexA: 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) @Volatile var blurParameters: BlurParameters = BlurParameters.DEFAULT @@ -72,14 +66,33 @@ class TiltShiftRenderer( private var cameraWidth: Int = 0 @Volatile private var cameraHeight: Int = 0 - - /** Display rotation as a Surface.ROTATION_* constant; affects effective aspect. */ - @Volatile - private var displayRotation: Int = Surface.ROTATION_0 - @Volatile private var vertexBufferDirty: Boolean = false + // Texture coordinates rotated 90° for portrait mode (back camera) + // (Camera sensors are landscape-oriented, we rotate to portrait) + private val texCoordsBack = floatArrayOf( + 1f, 1f, // Bottom left of screen -> bottom right of texture + 1f, 0f, // Bottom right of screen -> top right of texture + 0f, 1f, // Top left of screen -> bottom left of texture + 0f, 0f // Top right of screen -> top left of texture + ) + + // Texture coordinates for front camera (mirrored + rotated) + // Front camera needs horizontal mirror for natural selfie view + private val texCoordsFront = floatArrayOf( + 0f, 1f, // Bottom left of screen + 0f, 0f, // Bottom right of screen + 1f, 1f, // Top left of screen + 1f, 0f // Top right of screen + ) + + @Volatile + private var currentTexCoords = texCoordsBack + + @Volatile + private var updateTexCoordBuffer = false + override fun onSurfaceCreated(gl: GL10?, config: EGLConfig?) { GLES20.glClearColor(0f, 0f, 0f, 1f) @@ -91,8 +104,12 @@ class TiltShiftRenderer( cameraVertexBuffer.put(floatArrayOf(-1f, -1f, 1f, -1f, -1f, 1f, 1f, 1f)) cameraVertexBuffer.position(0) - // Fullscreen quad for blur passes (standard coords). The same buffer is reused - // for the camera passthrough texcoords — rotation is applied via uTexMatrix. + // Camera texcoord buffer (rotated for portrait) + cameraTexCoordBuffer = allocateFloatBuffer(8) + cameraTexCoordBuffer.put(currentTexCoords) + cameraTexCoordBuffer.position(0) + + // Fullscreen quad for blur passes (standard coords) fullscreenVertexBuffer = allocateFloatBuffer(8) fullscreenVertexBuffer.put(floatArrayOf(-1f, -1f, 1f, -1f, -1f, 1f, 1f, 1f)) fullscreenVertexBuffer.position(0) @@ -128,19 +145,20 @@ class TiltShiftRenderer( } override fun onDrawFrame(gl: GL10?) { - val st = surfaceTexture - 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() + surfaceTexture?.updateTexImage() if (vertexBufferDirty) { recomputeVertices() vertexBufferDirty = false } + if (updateTexCoordBuffer) { + cameraTexCoordBuffer.clear() + cameraTexCoordBuffer.put(currentTexCoords) + cameraTexCoordBuffer.position(0) + updateTexCoordBuffer = false + } + val params = blurParameters // --- Pass 1: Camera → FBO-A (passthrough with crop-to-fill) --- @@ -151,10 +169,10 @@ class TiltShiftRenderer( ) GLES20.glViewport(0, 0, surfaceWidth, surfaceHeight) GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT) - shader.usePassthrough(cameraTextureId, texMatrix, isFrontCamera) + shader.usePassthrough(cameraTextureId) drawQuad( shader.passthroughPositionLoc, shader.passthroughTexCoordLoc, - cameraVertexBuffer, fullscreenTexCoordBuffer + cameraVertexBuffer, cameraTexCoordBuffer ) // --- Pass 2: FBO-A → FBO-B (horizontal blur) --- @@ -185,7 +203,11 @@ class TiltShiftRenderer( } fun setFrontCamera(front: Boolean) { - isFrontCamera = front + if (isFrontCamera != front) { + isFrontCamera = front + currentTexCoords = if (front) texCoordsFront else texCoordsBack + updateTexCoordBuffer = true + } } fun setCameraResolution(width: Int, height: Int) { @@ -196,49 +218,6 @@ class TiltShiftRenderer( } } - /** Updates the display rotation so crop-to-fill picks the right effective aspect. */ - fun setDisplayRotation(rotation: Int) { - if (displayRotation != rotation) { - displayRotation = rotation - vertexBufferDirty = true - } - } - - /** - * Combines the sensor-to-buffer matrix with a rotation that compensates for - * the activity's current rotation. The activity rotates with the device - * (screenOrientation="fullSensor"), so the GL clip-space "up" direction - * tracks the device rather than the world. To keep world-up at screen-up - * 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() { shader.release() surfaceTexture?.release() @@ -275,24 +254,16 @@ class TiltShiftRenderer( /** * Recomputes camera vertex positions to achieve crop-to-fill. * - * The camera buffer is the sensor's native landscape resolution. The texMatrix - * rotates it to match the display, so the *effective* displayed dimensions - * depend on the display rotation: in portrait the buffer is rotated 90° - * (effective width = cameraHeight), in landscape it is unrotated. - * We scale the vertex quad so the frame fills the surface without stretching — - * the GPU clips the overflow. + * The camera sensor is landscape; after the 90° rotation applied via texture coordinates, + * the effective portrait dimensions are (cameraHeight × cameraWidth). We scale the vertex + * quad so the camera frame fills the surface without stretching — the GPU clips the overflow. */ private fun recomputeVertices() { var scaleX = 1f var scaleY = 1f if (cameraWidth > 0 && cameraHeight > 0 && surfaceWidth > 0 && surfaceHeight > 0) { - val isPortrait = displayRotation == Surface.ROTATION_0 || - displayRotation == Surface.ROTATION_180 - val effectiveW = if (isPortrait) cameraHeight else cameraWidth - val effectiveH = if (isPortrait) cameraWidth else cameraHeight - - val cameraRatio = effectiveW.toFloat() / effectiveH + val cameraRatio = cameraHeight.toFloat() / cameraWidth val screenRatio = surfaceWidth.toFloat() / surfaceHeight if (cameraRatio > screenRatio) { diff --git a/app/src/main/java/no/naiv/tiltshift/effect/TiltShiftShader.kt b/app/src/main/java/no/naiv/tiltshift/effect/TiltShiftShader.kt index ebbfb18..cdafd8f 100644 --- a/app/src/main/java/no/naiv/tiltshift/effect/TiltShiftShader.kt +++ b/app/src/main/java/no/naiv/tiltshift/effect/TiltShiftShader.kt @@ -20,15 +20,6 @@ import kotlin.math.sin */ 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) --- private var passthroughProgramId: Int = 0 @@ -38,8 +29,6 @@ class TiltShiftShader(private val context: Context) { var passthroughTexCoordLoc: Int = 0 private set private var passthroughTextureLoc: Int = 0 - private var passthroughTexMatrixLoc: Int = 0 - private var passthroughMirrorLoc: Int = 0 // --- Blur program (FBO → FBO/screen) --- @@ -50,8 +39,6 @@ class TiltShiftShader(private val context: Context) { var blurTexCoordLoc: Int = 0 private set private var blurTextureLoc: Int = 0 - private var blurTexMatrixLoc: Int = 0 - private var blurMirrorLoc: Int = 0 private var blurModeLoc: Int = 0 private var blurPositionXLoc: Int = 0 private var blurPositionYLoc: Int = 0 @@ -81,8 +68,6 @@ class TiltShiftShader(private val context: Context) { passthroughPositionLoc = GLES20.glGetAttribLocation(passthroughProgramId, "aPosition") passthroughTexCoordLoc = GLES20.glGetAttribLocation(passthroughProgramId, "aTexCoord") passthroughTextureLoc = GLES20.glGetUniformLocation(passthroughProgramId, "uTexture") - passthroughTexMatrixLoc = GLES20.glGetUniformLocation(passthroughProgramId, "uTexMatrix") - passthroughMirrorLoc = GLES20.glGetUniformLocation(passthroughProgramId, "uMirrorX") // Blur program val blurFragSource = loadShaderSource(R.raw.tiltshift_fragment) @@ -93,8 +78,6 @@ class TiltShiftShader(private val context: Context) { blurPositionLoc = GLES20.glGetAttribLocation(blurProgramId, "aPosition") blurTexCoordLoc = GLES20.glGetAttribLocation(blurProgramId, "aTexCoord") blurTextureLoc = GLES20.glGetUniformLocation(blurProgramId, "uTexture") - blurTexMatrixLoc = GLES20.glGetUniformLocation(blurProgramId, "uTexMatrix") - blurMirrorLoc = GLES20.glGetUniformLocation(blurProgramId, "uMirrorX") blurModeLoc = GLES20.glGetUniformLocation(blurProgramId, "uMode") blurPositionXLoc = GLES20.glGetUniformLocation(blurProgramId, "uPositionX") blurPositionYLoc = GLES20.glGetUniformLocation(blurProgramId, "uPositionY") @@ -113,19 +96,12 @@ class TiltShiftShader(private val context: Context) { /** * 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.glActiveTexture(GLES20.GL_TEXTURE0) GLES20.glBindTexture(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, cameraTextureId) 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.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.glUniform1f(blurPositionXLoc, params.positionX) GLES20.glUniform1f(blurPositionYLoc, params.positionY) diff --git a/app/src/main/java/no/naiv/tiltshift/ui/CameraScreen.kt b/app/src/main/java/no/naiv/tiltshift/ui/CameraScreen.kt index 167b376..bbb706d 100644 --- a/app/src/main/java/no/naiv/tiltshift/ui/CameraScreen.kt +++ b/app/src/main/java/no/naiv/tiltshift/ui/CameraScreen.kt @@ -112,7 +112,6 @@ fun CameraScreen( val isFrontCamera by viewModel.cameraManager.isFrontCamera.collectAsState() val previewResolution by viewModel.cameraManager.previewResolution.collectAsState() val cameraError by viewModel.cameraManager.error.collectAsState() - val currentRotation by viewModel.currentRotation.collectAsState() // Gallery picker val galleryLauncher = rememberLauncherForActivityResult( @@ -165,13 +164,6 @@ fun CameraScreen( } } - // Forward device rotation to renderer (aspect math) and CameraX (target rotation) - LaunchedEffect(currentRotation, renderer) { - renderer?.setDisplayRotation(currentRotation) - viewModel.cameraManager.setTargetRotation(currentRotation) - glSurfaceView?.requestRender() - } - // Start camera when surface texture is available LaunchedEffect(surfaceTexture) { surfaceTexture?.let { diff --git a/app/src/main/java/no/naiv/tiltshift/ui/ControlPanel.kt b/app/src/main/java/no/naiv/tiltshift/ui/ControlPanel.kt index dbc5112..ad94ad0 100644 --- a/app/src/main/java/no/naiv/tiltshift/ui/ControlPanel.kt +++ b/app/src/main/java/no/naiv/tiltshift/ui/ControlPanel.kt @@ -12,10 +12,7 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size 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.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.RestartAlt import androidx.compose.material3.Icon @@ -113,10 +110,8 @@ fun ControlPanel( Column( modifier = modifier .width(200.dp) - .wrapContentHeight() .clip(RoundedCornerShape(16.dp)) .background(AppColors.OverlayDarker) - .verticalScroll(rememberScrollState()) .padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp) ) { diff --git a/app/src/main/res/raw/tiltshift_vertex.glsl b/app/src/main/res/raw/tiltshift_vertex.glsl index ca79319..e009424 100644 --- a/app/src/main/res/raw/tiltshift_vertex.glsl +++ b/app/src/main/res/raw/tiltshift_vertex.glsl @@ -1,22 +1,12 @@ -// Vertex shader for tilt-shift effect. -// -// 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. +// Vertex shader for tilt-shift effect +// Passes through position and calculates texture coordinates attribute vec4 aPosition; attribute vec2 aTexCoord; -uniform mat4 uTexMatrix; -uniform float uMirrorX; - varying vec2 vTexCoord; void main() { gl_Position = aPosition; - vec2 tc = (uTexMatrix * vec4(aTexCoord, 0.0, 1.0)).xy; - if (uMirrorX > 0.5) tc.x = 1.0 - tc.x; - vTexCoord = tc; + vTexCoord = aTexCoord; } diff --git a/version.properties b/version.properties index 34449a3..ba10bb5 100644 --- a/version.properties +++ b/version.properties @@ -1,4 +1,4 @@ versionMajor=1 versionMinor=1 -versionPatch=9 -versionCode=11 +versionPatch=10 +versionCode=12 From a2dfa7db3d7d2d443a46822acce9ed2e140a0106 Mon Sep 17 00:00:00 2001 From: Ole-Morten Duesund Date: Mon, 11 May 2026 16:12:29 +0200 Subject: [PATCH 5/9] Make camera image follow device rotation (4-orientation texcoord table) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../tiltshift/effect/TiltShiftRenderer.kt | 75 +++++++++++++------ .../java/no/naiv/tiltshift/ui/CameraScreen.kt | 8 ++ version.properties | 4 +- 3 files changed, 64 insertions(+), 23 deletions(-) diff --git a/app/src/main/java/no/naiv/tiltshift/effect/TiltShiftRenderer.kt b/app/src/main/java/no/naiv/tiltshift/effect/TiltShiftRenderer.kt index d170d58..dc2b332 100644 --- a/app/src/main/java/no/naiv/tiltshift/effect/TiltShiftRenderer.kt +++ b/app/src/main/java/no/naiv/tiltshift/effect/TiltShiftRenderer.kt @@ -6,6 +6,7 @@ import android.opengl.GLES11Ext import android.opengl.GLES20 import android.opengl.GLSurfaceView import android.util.Log +import android.view.Surface import java.nio.ByteBuffer import java.nio.ByteOrder import java.nio.FloatBuffer @@ -69,26 +70,32 @@ class TiltShiftRenderer( @Volatile private var vertexBufferDirty: Boolean = false - // Texture coordinates rotated 90° for portrait mode (back camera) - // (Camera sensors are landscape-oriented, we rotate to portrait) - private val texCoordsBack = floatArrayOf( - 1f, 1f, // Bottom left of screen -> bottom right of texture - 1f, 0f, // Bottom right of screen -> top right of texture - 0f, 1f, // Top left of screen -> bottom left of texture - 0f, 0f // Top right of screen -> top left of texture + // 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 ) - // Texture coordinates for front camera (mirrored + rotated) - // Front camera needs horizontal mirror for natural selfie view - private val texCoordsFront = floatArrayOf( - 0f, 1f, // Bottom left of screen - 0f, 0f, // Bottom right of screen - 1f, 1f, // Top left of screen - 1f, 0f // Top right of screen + // 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 ) @Volatile - private var currentTexCoords = texCoordsBack + private var displayRotation: Int = Surface.ROTATION_0 + + @Volatile + private var currentTexCoords = texCoordsBackByRotation[Surface.ROTATION_0] @Volatile private var updateTexCoordBuffer = false @@ -205,8 +212,7 @@ class TiltShiftRenderer( fun setFrontCamera(front: Boolean) { if (isFrontCamera != front) { isFrontCamera = front - currentTexCoords = if (front) texCoordsFront else texCoordsBack - updateTexCoordBuffer = true + refreshTexCoords() } } @@ -218,6 +224,27 @@ class TiltShiftRenderer( } } + /** + * 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) { + if (displayRotation != rotation) { + displayRotation = rotation + refreshTexCoords() + vertexBufferDirty = true + } + } + + private fun refreshTexCoords() { + val table = if (isFrontCamera) texCoordsFrontByRotation else texCoordsBackByRotation + val idx = displayRotation.coerceIn(0, table.size - 1) + currentTexCoords = table[idx] + updateTexCoordBuffer = true + } + fun release() { shader.release() surfaceTexture?.release() @@ -254,16 +281,22 @@ class TiltShiftRenderer( /** * Recomputes camera vertex positions to achieve crop-to-fill. * - * The camera sensor is landscape; after the 90° rotation applied via texture coordinates, - * the effective portrait dimensions are (cameraHeight × cameraWidth). We scale the vertex - * quad so the camera frame fills the surface without stretching — the GPU clips the overflow. + * The camera sensor is landscape; after the orientation-dependent texcoord + * rotation, the effective dimensions seen on screen are either swapped + * (portrait orientations) or kept (landscape orientations). We scale the + * vertex quad so the camera frame fills the surface without stretching — + * the GPU clips the overflow. */ private fun recomputeVertices() { var scaleX = 1f var scaleY = 1f if (cameraWidth > 0 && cameraHeight > 0 && surfaceWidth > 0 && surfaceHeight > 0) { - val cameraRatio = cameraHeight.toFloat() / cameraWidth + val isPortrait = displayRotation == Surface.ROTATION_0 || + displayRotation == Surface.ROTATION_180 + val effectiveW = if (isPortrait) cameraHeight else cameraWidth + val effectiveH = if (isPortrait) cameraWidth else cameraHeight + val cameraRatio = effectiveW.toFloat() / effectiveH val screenRatio = surfaceWidth.toFloat() / surfaceHeight if (cameraRatio > screenRatio) { diff --git a/app/src/main/java/no/naiv/tiltshift/ui/CameraScreen.kt b/app/src/main/java/no/naiv/tiltshift/ui/CameraScreen.kt index bbb706d..d5999ed 100644 --- a/app/src/main/java/no/naiv/tiltshift/ui/CameraScreen.kt +++ b/app/src/main/java/no/naiv/tiltshift/ui/CameraScreen.kt @@ -164,6 +164,14 @@ fun CameraScreen( } } + // 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) { + renderer?.setDisplayRotation(currentRotation) + glSurfaceView?.requestRender() + } + // Start camera when surface texture is available LaunchedEffect(surfaceTexture) { surfaceTexture?.let { diff --git a/version.properties b/version.properties index ba10bb5..aaf41eb 100644 --- a/version.properties +++ b/version.properties @@ -1,4 +1,4 @@ versionMajor=1 versionMinor=1 -versionPatch=10 -versionCode=12 +versionPatch=12 +versionCode=14 From 5b553c719667b86d0ef37e661b7a7ff03785a75c Mon Sep 17 00:00:00 2001 From: Ole-Morten Duesund Date: Mon, 11 May 2026 16:22:54 +0200 Subject: [PATCH 6/9] Drive renderer rotation from Display.rotation, not OrientationEventListener MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OrientationEventListener fires continuously on the raw accelerometer tilt and crosses the ROTATION_90 / ROTATION_270 boundary at 45° — well before the system actually rotates the activity. The renderer was swapping its texcoord buffer at 45° tilt while the GL surface and Compose layout were still in the previous orientation, so for the few degrees between "OrientationEventListener fires" and "activity rotates" the camera image rendered at the wrong rotation. Past that window it snapped back into sync. Use LocalConfiguration + Display.rotation to source the renderer's rotation. Configuration only changes when the activity has actually rotated, so the texcoord buffer flips in lock-step with the GL surface and there is no transient mis-orientation. OrientationEventListener is still used by capture for EXIF metadata. Bump to 1.1.13. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../java/no/naiv/tiltshift/ui/CameraScreen.kt | 23 +++++++++++++++---- version.properties | 4 ++-- 2 files changed, 20 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/no/naiv/tiltshift/ui/CameraScreen.kt b/app/src/main/java/no/naiv/tiltshift/ui/CameraScreen.kt index d5999ed..763d4e4 100644 --- a/app/src/main/java/no/naiv/tiltshift/ui/CameraScreen.kt +++ b/app/src/main/java/no/naiv/tiltshift/ui/CameraScreen.kt @@ -1,9 +1,13 @@ package no.naiv.tiltshift.ui +import android.content.Context import android.content.Intent import android.graphics.SurfaceTexture +import android.hardware.display.DisplayManager import android.opengl.GLSurfaceView import android.util.Log +import android.view.Display +import android.view.Surface import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut @@ -164,11 +168,20 @@ fun CameraScreen( } } - // 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) { - renderer?.setDisplayRotation(currentRotation) + // Forward the activity's actual rotation (Display.rotation) to the + // renderer so the camera image stays world-aligned as the activity rotates + // with the device. Don't drive this from OrientationEventListener — its + // 45° threshold fires *before* the activity has rotated, briefly leaving + // the texcoord set out of sync with the GL surface orientation. + // LocalConfiguration triggers a recomposition on configuration change, + // which is when Display.rotation can have changed. + val configuration = androidx.compose.ui.platform.LocalConfiguration.current + val displayRotation = remember(configuration) { + val displayManager = context.getSystemService(Context.DISPLAY_SERVICE) as DisplayManager + displayManager.getDisplay(Display.DEFAULT_DISPLAY)?.rotation ?: Surface.ROTATION_0 + } + LaunchedEffect(displayRotation, renderer) { + renderer?.setDisplayRotation(displayRotation) glSurfaceView?.requestRender() } diff --git a/version.properties b/version.properties index aaf41eb..93b21cf 100644 --- a/version.properties +++ b/version.properties @@ -1,4 +1,4 @@ versionMajor=1 versionMinor=1 -versionPatch=12 -versionCode=14 +versionPatch=13 +versionCode=15 From e4892c4b12a63f02ac804253ee4a5e8c63594d29 Mon Sep 17 00:00:00 2001 From: Ole-Morten Duesund Date: Mon, 11 May 2026 16:44:23 +0200 Subject: [PATCH 7/9] Lock activity to portrait; drop all camera-image rotation tracking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stop trying to rotate the camera image based on device orientation. The activity is now locked to portrait (screenOrientation="portrait"), so the GL surface stays portrait-sized regardless of how the device is held, and the camera passthrough goes back to the simple texCoordsBack 90° rotation that was working before any of the v1.1.6–1.1.13 attempts at landscape support. Net effect: the camera image stays in the device's portrait frame and visually follows the phone as it tilts (since there is no inverse rotation cancelling it). The UI is also locked to the portrait layout for now — a follow-up will add Modifier.graphicsLayer rotations to the icon overlays so they stay readable when the phone is held sideways. screenOrientation switched from fullSensor to portrait; the rest of the file changes are reverts of the orientation plumbing introduced in v1.1.6 and its follow-ups. Bump to 1.1.14. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/src/main/AndroidManifest.xml | 2 +- .../tiltshift/effect/TiltShiftRenderer.kt | 75 ++++++------------- .../java/no/naiv/tiltshift/ui/CameraScreen.kt | 21 ------ version.properties | 4 +- 4 files changed, 24 insertions(+), 78 deletions(-) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index c56c4e9..9fed438 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -31,7 +31,7 @@ diff --git a/app/src/main/java/no/naiv/tiltshift/effect/TiltShiftRenderer.kt b/app/src/main/java/no/naiv/tiltshift/effect/TiltShiftRenderer.kt index dc2b332..d170d58 100644 --- a/app/src/main/java/no/naiv/tiltshift/effect/TiltShiftRenderer.kt +++ b/app/src/main/java/no/naiv/tiltshift/effect/TiltShiftRenderer.kt @@ -6,7 +6,6 @@ import android.opengl.GLES11Ext import android.opengl.GLES20 import android.opengl.GLSurfaceView import android.util.Log -import android.view.Surface import java.nio.ByteBuffer import java.nio.ByteOrder import java.nio.FloatBuffer @@ -70,32 +69,26 @@ class TiltShiftRenderer( @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 + // Texture coordinates rotated 90° for portrait mode (back camera) + // (Camera sensors are landscape-oriented, we rotate to portrait) + private val texCoordsBack = floatArrayOf( + 1f, 1f, // Bottom left of screen -> bottom right of texture + 1f, 0f, // Bottom right of screen -> top right of texture + 0f, 1f, // Top left of screen -> bottom left of texture + 0f, 0f // Top right of screen -> top left of texture ) - // 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 + // Texture coordinates for front camera (mirrored + rotated) + // Front camera needs horizontal mirror for natural selfie view + private val texCoordsFront = floatArrayOf( + 0f, 1f, // Bottom left of screen + 0f, 0f, // Bottom right of screen + 1f, 1f, // Top left of screen + 1f, 0f // Top right of screen ) @Volatile - private var displayRotation: Int = Surface.ROTATION_0 - - @Volatile - private var currentTexCoords = texCoordsBackByRotation[Surface.ROTATION_0] + private var currentTexCoords = texCoordsBack @Volatile private var updateTexCoordBuffer = false @@ -212,7 +205,8 @@ class TiltShiftRenderer( fun setFrontCamera(front: Boolean) { if (isFrontCamera != front) { isFrontCamera = front - refreshTexCoords() + currentTexCoords = if (front) texCoordsFront else texCoordsBack + updateTexCoordBuffer = true } } @@ -224,27 +218,6 @@ class TiltShiftRenderer( } } - /** - * 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) { - if (displayRotation != rotation) { - displayRotation = rotation - refreshTexCoords() - vertexBufferDirty = true - } - } - - private fun refreshTexCoords() { - val table = if (isFrontCamera) texCoordsFrontByRotation else texCoordsBackByRotation - val idx = displayRotation.coerceIn(0, table.size - 1) - currentTexCoords = table[idx] - updateTexCoordBuffer = true - } - fun release() { shader.release() surfaceTexture?.release() @@ -281,22 +254,16 @@ class TiltShiftRenderer( /** * Recomputes camera vertex positions to achieve crop-to-fill. * - * The camera sensor is landscape; after the orientation-dependent texcoord - * rotation, the effective dimensions seen on screen are either swapped - * (portrait orientations) or kept (landscape orientations). We scale the - * vertex quad so the camera frame fills the surface without stretching — - * the GPU clips the overflow. + * The camera sensor is landscape; after the 90° rotation applied via texture coordinates, + * the effective portrait dimensions are (cameraHeight × cameraWidth). We scale the vertex + * quad so the camera frame fills the surface without stretching — the GPU clips the overflow. */ private fun recomputeVertices() { var scaleX = 1f var scaleY = 1f if (cameraWidth > 0 && cameraHeight > 0 && surfaceWidth > 0 && surfaceHeight > 0) { - val isPortrait = displayRotation == Surface.ROTATION_0 || - displayRotation == Surface.ROTATION_180 - val effectiveW = if (isPortrait) cameraHeight else cameraWidth - val effectiveH = if (isPortrait) cameraWidth else cameraHeight - val cameraRatio = effectiveW.toFloat() / effectiveH + val cameraRatio = cameraHeight.toFloat() / cameraWidth val screenRatio = surfaceWidth.toFloat() / surfaceHeight if (cameraRatio > screenRatio) { diff --git a/app/src/main/java/no/naiv/tiltshift/ui/CameraScreen.kt b/app/src/main/java/no/naiv/tiltshift/ui/CameraScreen.kt index 763d4e4..bbb706d 100644 --- a/app/src/main/java/no/naiv/tiltshift/ui/CameraScreen.kt +++ b/app/src/main/java/no/naiv/tiltshift/ui/CameraScreen.kt @@ -1,13 +1,9 @@ package no.naiv.tiltshift.ui -import android.content.Context import android.content.Intent import android.graphics.SurfaceTexture -import android.hardware.display.DisplayManager import android.opengl.GLSurfaceView import android.util.Log -import android.view.Display -import android.view.Surface import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut @@ -168,23 +164,6 @@ fun CameraScreen( } } - // Forward the activity's actual rotation (Display.rotation) to the - // renderer so the camera image stays world-aligned as the activity rotates - // with the device. Don't drive this from OrientationEventListener — its - // 45° threshold fires *before* the activity has rotated, briefly leaving - // the texcoord set out of sync with the GL surface orientation. - // LocalConfiguration triggers a recomposition on configuration change, - // which is when Display.rotation can have changed. - val configuration = androidx.compose.ui.platform.LocalConfiguration.current - val displayRotation = remember(configuration) { - val displayManager = context.getSystemService(Context.DISPLAY_SERVICE) as DisplayManager - displayManager.getDisplay(Display.DEFAULT_DISPLAY)?.rotation ?: Surface.ROTATION_0 - } - LaunchedEffect(displayRotation, renderer) { - renderer?.setDisplayRotation(displayRotation) - glSurfaceView?.requestRender() - } - // Start camera when surface texture is available LaunchedEffect(surfaceTexture) { surfaceTexture?.let { diff --git a/version.properties b/version.properties index 93b21cf..f37cebb 100644 --- a/version.properties +++ b/version.properties @@ -1,4 +1,4 @@ versionMajor=1 versionMinor=1 -versionPatch=13 -versionCode=15 +versionPatch=14 +versionCode=16 From b057613bac81a799ac977a1290f506e9a301b46b Mon Sep 17 00:00:00 2001 From: Ole-Morten Duesund Date: Mon, 11 May 2026 16:59:28 +0200 Subject: [PATCH 8/9] Write the user's physical tilt into the saved photo's EXIF orientation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Capture saves the bitmap in the device's portrait frame (CameraX rotates the sensor 90° to match the locked-portrait activity), so a photo taken while the phone was held in landscape lands on disk as a portrait-shaped bitmap with world-up pointing to the side. Tag the EXIF orientation with the user's actual tilt at shutter time (OrientationEventListener-derived deviceRotation, which up to now was passed in but ignored), so gallery viewers rotate the photo to match how the phone was held. Bump to 1.1.15. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../no/naiv/tiltshift/camera/ImageCaptureHandler.kt | 12 +++++++++++- version.properties | 4 ++-- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/no/naiv/tiltshift/camera/ImageCaptureHandler.kt b/app/src/main/java/no/naiv/tiltshift/camera/ImageCaptureHandler.kt index b0401e7..b93db9a 100644 --- a/app/src/main/java/no/naiv/tiltshift/camera/ImageCaptureHandler.kt +++ b/app/src/main/java/no/naiv/tiltshift/camera/ImageCaptureHandler.kt @@ -24,6 +24,7 @@ import kotlin.coroutines.resume import kotlin.math.cos import kotlin.math.sin import kotlin.math.sqrt +import no.naiv.tiltshift.util.OrientationDetector /** * Handles capturing photos with the tilt-shift effect applied. @@ -121,10 +122,19 @@ class ImageCaptureHandler( var thumbnail: Bitmap? = null try { thumbnail = createThumbnail(captureResult.processed) + // Camera bitmap is in the device's portrait frame (CameraX + // rotated the sensor 90° because the activity is locked to + // portrait). Tag the EXIF with the user's physical tilt + // when they pressed the shutter so viewers display the + // photo right-side-up regardless of how the phone was held. + val exifOrientation = OrientationDetector.degreesToExifOrientation( + OrientationDetector.rotationToDegrees(deviceRotation), + isFrontCamera + ) val result = photoSaver.saveBitmapPair( original = captureResult.original, processed = captureResult.processed, - orientation = ExifInterface.ORIENTATION_NORMAL, + orientation = exifOrientation, location = location ) if (result is SaveResult.Success) { diff --git a/version.properties b/version.properties index f37cebb..214906f 100644 --- a/version.properties +++ b/version.properties @@ -1,4 +1,4 @@ versionMajor=1 versionMinor=1 -versionPatch=14 -versionCode=16 +versionPatch=15 +versionCode=17 From 2a826294613e183dd2264a7d69a7a4f862b49b0b Mon Sep 17 00:00:00 2001 From: Ole-Morten Duesund Date: Mon, 11 May 2026 17:06:37 +0200 Subject: [PATCH 9/9] Document orientation policy and emulator-testing pitfalls in CLAUDE.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Capture two things future sessions need to know up front: (1) the activity is locked to portrait and the camera image stays in the device frame on purpose — eight releases of orientation-tracking attempts (v1.1.6 → v1.1.13) all got reverted, and per-orientation correctness now lives in the EXIF tag rather than the GL pipeline; (2) the Pixel 6 emulator's default virtual scene is too symmetric to verify rotation visually, `emu rotate` decrements rather than increments, and there is a startup window where camera bitmaps look black. Co-Authored-By: Claude Opus 4.7 (1M context) --- CLAUDE.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index 6e1a655..249fe6a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -86,6 +86,21 @@ Bitmaps emitted to `StateFlow`s are **never eagerly recycled** immediately after - Error/success dismiss indicators use cancellable `Job` tracking to prevent race conditions - `writeExifToUri()` returns boolean and logs at ERROR level on failure +### Orientation policy (do not change without asking) + +- Activity is `screenOrientation="portrait"` in the manifest. The GL surface and Compose layout therefore never rotate. +- The camera passthrough uses a fixed 90° texcoord rotation (`texCoordsBack`/`texCoordsFront`). There is no `setTargetRotation`/`setDisplayRotation`/`getTransformMatrix`-based rotation tracking — past attempts (v1.1.6 through v1.1.13) all introduced subtle bugs and were reverted. +- The camera image lives in the device's portrait frame and visually follows the phone as it tilts. Per-orientation correctness comes from the EXIF orientation tag written at capture (`OrientationDetector.degreesToExifOrientation(rotationToDegrees(deviceRotation), isFrontCamera)`), not from rotating the bitmap. +- `OrientationEventListener` (`viewModel.currentRotation`) is for EXIF only. It is **not** the same as `Display.rotation`: it fires at the 45° tilt threshold while the activity rotates later, so it must not drive anything that has to stay in sync with the GL surface. +- `SurfaceTexture.getTransformMatrix()` with a custom `SurfaceProvider` does not change on `Preview.targetRotation` updates; rebinding doesn't reliably help either. Don't go down that road again. + +### Local Android testing + +- AVD: `tilfluktsrom` (Pixel 6, API 35, Google APIs) at `~/.android/avd/`. Boot with `emulator -avd tilfluktsrom -no-snapshot-save -gpu swiftshader_indirect -no-audio`. +- `adb -s emulator-5554 emu rotate` cycles `ROTATION_0 → 3 → 2 → 1 → 0` (decrements by 90°), not the other way. +- The default virtual scene is too rotationally symmetric to verify which way is "up". For orientation testing, pass `-virtualscene-poster wall=/path/to/marker.png` and ensure the scene camera faces the wall, or just don't trust emulator screenshots as proof of correct orientation. +- Camera bitmaps captured at startup tend to look black for a few seconds while CameraX rebinds. Wait ≥10 s after `am start` before screenshotting. + ## Permissions | Permission | Purpose | Notes |