Compare commits

..

No commits in common. "main" and "v1.1.12" have entirely different histories.

6 changed files with 66 additions and 50 deletions

View file

@ -86,21 +86,6 @@ Bitmaps emitted to `StateFlow`s are **never eagerly recycled** immediately after
- Error/success dismiss indicators use cancellable `Job` tracking to prevent race conditions - Error/success dismiss indicators use cancellable `Job` tracking to prevent race conditions
- `writeExifToUri()` returns boolean and logs at ERROR level on failure - `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 ## Permissions
| Permission | Purpose | Notes | | Permission | Purpose | Notes |

View file

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

View file

@ -24,7 +24,6 @@ import kotlin.coroutines.resume
import kotlin.math.cos import kotlin.math.cos
import kotlin.math.sin import kotlin.math.sin
import kotlin.math.sqrt import kotlin.math.sqrt
import no.naiv.tiltshift.util.OrientationDetector
/** /**
* Handles capturing photos with the tilt-shift effect applied. * Handles capturing photos with the tilt-shift effect applied.
@ -122,19 +121,10 @@ class ImageCaptureHandler(
var thumbnail: Bitmap? = null var thumbnail: Bitmap? = null
try { try {
thumbnail = createThumbnail(captureResult.processed) 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( val result = photoSaver.saveBitmapPair(
original = captureResult.original, original = captureResult.original,
processed = captureResult.processed, processed = captureResult.processed,
orientation = exifOrientation, orientation = ExifInterface.ORIENTATION_NORMAL,
location = location location = location
) )
if (result is SaveResult.Success) { if (result is SaveResult.Success) {

View file

@ -6,6 +6,7 @@ import android.opengl.GLES11Ext
import android.opengl.GLES20 import android.opengl.GLES20
import android.opengl.GLSurfaceView import android.opengl.GLSurfaceView
import android.util.Log import android.util.Log
import android.view.Surface
import java.nio.ByteBuffer import java.nio.ByteBuffer
import java.nio.ByteOrder import java.nio.ByteOrder
import java.nio.FloatBuffer import java.nio.FloatBuffer
@ -69,26 +70,32 @@ class TiltShiftRenderer(
@Volatile @Volatile
private var vertexBufferDirty: Boolean = false private var vertexBufferDirty: Boolean = false
// Texture coordinates rotated 90° for portrait mode (back camera) // Texture coordinates for the back camera, indexed by Surface.ROTATION_*.
// (Camera sensors are landscape-oriented, we rotate to portrait) // The base orientation (index 0) applies the 90° CCW rotation that maps
private val texCoordsBack = floatArrayOf( // the landscape sensor frame to a portrait display. Indices 1/2/3 layer
1f, 1f, // Bottom left of screen -> bottom right of texture // additional CCW rotations on top so the activity's rotation is
1f, 0f, // Bottom right of screen -> top right of texture // compensated and world-up stays at clip-space-top.
0f, 1f, // Top left of screen -> bottom left of texture private val texCoordsBackByRotation = arrayOf(
0f, 0f // Top right of screen -> top left of texture 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 variants: same as back, but horizontally mirrored
// Front camera needs horizontal mirror for natural selfie view // for the natural selfie view.
private val texCoordsFront = floatArrayOf( private val texCoordsFrontByRotation = arrayOf(
0f, 1f, // Bottom left of screen floatArrayOf(0f, 1f, 0f, 0f, 1f, 1f, 1f, 0f), // ROTATION_0
0f, 0f, // Bottom right of screen floatArrayOf(0f, 0f, 1f, 0f, 0f, 1f, 1f, 1f), // ROTATION_90
1f, 1f, // Top left of screen floatArrayOf(1f, 0f, 1f, 1f, 0f, 0f, 0f, 1f), // ROTATION_180
1f, 0f // Top right of screen floatArrayOf(1f, 1f, 0f, 1f, 1f, 0f, 0f, 0f) // ROTATION_270
) )
@Volatile @Volatile
private var currentTexCoords = texCoordsBack private var displayRotation: Int = Surface.ROTATION_0
@Volatile
private var currentTexCoords = texCoordsBackByRotation[Surface.ROTATION_0]
@Volatile @Volatile
private var updateTexCoordBuffer = false private var updateTexCoordBuffer = false
@ -205,8 +212,7 @@ class TiltShiftRenderer(
fun setFrontCamera(front: Boolean) { fun setFrontCamera(front: Boolean) {
if (isFrontCamera != front) { if (isFrontCamera != front) {
isFrontCamera = front isFrontCamera = front
currentTexCoords = if (front) texCoordsFront else texCoordsBack refreshTexCoords()
updateTexCoordBuffer = true
} }
} }
@ -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() { fun release() {
shader.release() shader.release()
surfaceTexture?.release() surfaceTexture?.release()
@ -254,16 +281,22 @@ class TiltShiftRenderer(
/** /**
* Recomputes camera vertex positions to achieve crop-to-fill. * Recomputes camera vertex positions to achieve crop-to-fill.
* *
* The camera sensor is landscape; after the 90° rotation applied via texture coordinates, * The camera sensor is landscape; after the orientation-dependent texcoord
* the effective portrait dimensions are (cameraHeight × cameraWidth). We scale the vertex * rotation, the effective dimensions seen on screen are either swapped
* quad so the camera frame fills the surface without stretching the GPU clips the overflow. * (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() { private fun recomputeVertices() {
var scaleX = 1f var scaleX = 1f
var scaleY = 1f var scaleY = 1f
if (cameraWidth > 0 && cameraHeight > 0 && surfaceWidth > 0 && surfaceHeight > 0) { 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 val screenRatio = surfaceWidth.toFloat() / surfaceHeight
if (cameraRatio > screenRatio) { if (cameraRatio > screenRatio) {

View file

@ -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 // Start camera when surface texture is available
LaunchedEffect(surfaceTexture) { LaunchedEffect(surfaceTexture) {
surfaceTexture?.let { surfaceTexture?.let {

View file

@ -1,4 +1,4 @@
versionMajor=1 versionMajor=1
versionMinor=1 versionMinor=1
versionPatch=15 versionPatch=12
versionCode=17 versionCode=14