Compare commits
No commits in common. "main" and "v1.1.13" have entirely different histories.
6 changed files with 79 additions and 50 deletions
15
CLAUDE.md
15
CLAUDE.md
|
|
@ -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 |
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,13 @@
|
||||||
package no.naiv.tiltshift.ui
|
package no.naiv.tiltshift.ui
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.graphics.SurfaceTexture
|
import android.graphics.SurfaceTexture
|
||||||
|
import android.hardware.display.DisplayManager
|
||||||
import android.opengl.GLSurfaceView
|
import android.opengl.GLSurfaceView
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
|
import android.view.Display
|
||||||
|
import android.view.Surface
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
import androidx.compose.animation.fadeIn
|
import androidx.compose.animation.fadeIn
|
||||||
import androidx.compose.animation.fadeOut
|
import androidx.compose.animation.fadeOut
|
||||||
|
|
@ -164,6 +168,23 @@ 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
|
// Start camera when surface texture is available
|
||||||
LaunchedEffect(surfaceTexture) {
|
LaunchedEffect(surfaceTexture) {
|
||||||
surfaceTexture?.let {
|
surfaceTexture?.let {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
versionMajor=1
|
versionMajor=1
|
||||||
versionMinor=1
|
versionMinor=1
|
||||||
versionPatch=15
|
versionPatch=13
|
||||||
versionCode=17
|
versionCode=15
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue