Compare commits

..

12 commits

Author SHA1 Message Date
2a82629461 Document orientation policy and emulator-testing pitfalls in CLAUDE.md
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) <noreply@anthropic.com>
2026-05-11 17:06:37 +02:00
b057613bac Write the user's physical tilt into the saved photo's EXIF orientation
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) <noreply@anthropic.com>
2026-05-11 16:59:28 +02:00
e4892c4b12 Lock activity to portrait; drop all camera-image rotation tracking
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) <noreply@anthropic.com>
2026-05-11 16:44:23 +02:00
5b553c7196 Drive renderer rotation from Display.rotation, not OrientationEventListener
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) <noreply@anthropic.com>
2026-05-11 16:22:54 +02:00
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
1cd2b0a57c Flip rotation-correction angles for both landscape orientations
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) <noreply@anthropic.com>
2026-05-11 15:46:31 +02:00
c0bab85d63 Revert "Revert orientation tracking on the camera image"
This reverts commit 4f8661f648.
2026-05-11 15:29:59 +02:00
4f8661f648 Revert orientation tracking on the camera image
Roll back the rendering-pipeline changes from v1.1.6 (and the
subsequent attempts in 1.1.7/1.1.8/1.1.9): no more
SurfaceTexture transform-matrix-driven re-orientation, no more
rebinding on rotation, no more inverse-rotation correction. The
camera passthrough goes back to fixed portrait-oriented texcoords
and the crop-to-fill math treats the camera buffer as portrait
unconditionally.

The activity stays `fullSensor`, so the Compose UI and the GL
blur passes continue to follow the device orientation — only the
camera image itself is left untouched, which matches the
"normal camera doesn't flip the picture when rotating the phone"
behaviour requested.

Bump to 1.1.10.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 14:59:18 +02:00
6d7be66341 Fix one landscape orientation rendering image upside down
`SurfaceTexture.getTransformMatrix()` for a custom SurfaceProvider does
not vary with `Preview.targetRotation` — it only encodes the static
sensor-to-buffer transform. The v1.1.8 rebind-on-rotation fix did
update Preview's target rotation, but the matrix returned to the GL
renderer was identical across all four device orientations. Combined
with the activity rotating under fullSensor (so the GL clip-space "up"
direction tracks the device, not the world), one of the two landscape
orientations rendered the image upside down on a real device. The
emulator masked this because its virtual scene is roughly symmetric.

Compose the missing piece on the GL thread: rotate the texcoord
sampling pattern around its centre by the inverse of the activity
rotation before sampling the camera texture. The four orientations
now produce four distinct matrices, keeping world-up at screen-up in
all of them while leaving portrait unchanged.

Bump to 1.1.9.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 14:46:28 +02:00
f0d81db068 Fix landscape preview by rebinding on rotation change
`Preview.targetRotation = …` on an already-bound use case does not
refresh the live SurfaceTexture's transform matrix or trigger a new
SurfaceRequest with a rotation-appropriate buffer size — the new
rotation only applies to subsequently bound streams. With our custom
SurfaceProvider, the result was that rotating to landscape left the
camera still writing portrait-oriented frames into the now-landscape
GL surface, so the preview appeared to rotate with the device instead
of switching to landscape.

Rebind the camera use cases when the target rotation changes so
CameraX fires a fresh SurfaceRequest with the correct resolution
and matrix. Also revert the OrientationDetector mapping back to its
original (matches Display.rotation for a fullSensor activity, which
is what setTargetRotation expects); the previous "fix" went the wrong
direction and was not the root cause.

Bump to 1.1.8.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 14:05:45 +02:00
11 changed files with 84 additions and 137 deletions

View file

@ -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 |

View file

@ -31,7 +31,7 @@
<activity
android:name=".MainActivity"
android:exported="true"
android:screenOrientation="fullSensor"
android:screenOrientation="portrait"
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden"
android:windowSoftInputMode="adjustNothing"
android:theme="@style/Theme.TiltShiftCamera">

View file

@ -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<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.
* 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,19 +173,6 @@ class CameraManager(private val context: Context) {
}
}
/**
* Updates the target rotation for Preview and ImageCapture use cases.
* Call when the device rotates: this rotates the SurfaceTexture transform matrix
* (so the GL preview stays upright) and tags captures with the right orientation.
* Safe to call on the main thread; CameraX permits live target-rotation updates.
*/
fun setTargetRotation(rotation: Int) {
if (targetRotation == rotation) return
targetRotation = rotation
preview?.targetRotation = rotation
imageCapture?.targetRotation = rotation
}
/**
* 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).

View file

@ -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) {

View file

@ -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
@ -39,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
@ -54,9 +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)
// Current effect parameters (updated from UI thread)
@Volatile
var blurParameters: BlurParameters = BlurParameters.DEFAULT
@ -69,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)
@ -88,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)
@ -125,17 +145,20 @@ class TiltShiftRenderer(
}
override fun onDrawFrame(gl: GL10?) {
val st = surfaceTexture
st?.updateTexImage()
// Pull the latest sensor-to-display transform; updated by SurfaceTexture each frame
// and reflects whatever rotation CameraX requested via Preview.targetRotation.
st?.getTransformMatrix(texMatrix)
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) ---
@ -146,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) ---
@ -180,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) {
@ -191,14 +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
}
}
fun release() {
shader.release()
surfaceTexture?.release()
@ -235,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) {

View file

@ -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)

View file

@ -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 {

View file

@ -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)
) {

View file

@ -24,17 +24,11 @@ class OrientationDetector(private val context: Context) {
override fun onOrientationChanged(orientation: Int) {
if (orientation == ORIENTATION_UNKNOWN) return
// OrientationEventListener reports the device's physical rotation
// (clockwise from natural). Surface.ROTATION_* describes the screen's
// logical rotation, which is the opposite direction — so device tilted
// 90° CW (orientation ≈ 90, "left side at top") maps to ROTATION_270,
// and 90° CCW (orientation ≈ 270) maps to ROTATION_90. Matches the
// CameraX docs example for setTargetRotation.
val rotation = when {
orientation >= 315 || orientation < 45 -> Surface.ROTATION_0
orientation >= 45 && orientation < 135 -> Surface.ROTATION_270
orientation >= 45 && orientation < 135 -> Surface.ROTATION_90
orientation >= 135 && orientation < 225 -> Surface.ROTATION_180
else -> Surface.ROTATION_90
else -> Surface.ROTATION_270
}
if (rotation != lastRotation) {

View file

@ -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;
}

View file

@ -1,4 +1,4 @@
versionMajor=1
versionMinor=1
versionPatch=7
versionCode=9
versionPatch=15
versionCode=17